top of page

Creating a Force Graph using React, D3, and PixiJS



In the app we created we faced a very painful performance problem. While D3 helped us to create the relevant force graph we needed to show on screen, the data source we were using became very large. When using D3, the graph representation underneath is created using SVG and that means that when the data source becomes larger the amount of SVG elements increase. The more SVG elements you have on screen the less performant the app becomes.


So, how can we solve the problem? What if we could transfer D3 SVG representation into canvas representation. Would that help?

In our app it helped.


Enter PixiJS

PixiJS is a flexible 2D WebGL renderer library which is working on top of HTML5 canvas element.


Note - I won’t get into PixiJS fundamentals in this post and I encourage you to go to it’s website for further reading.


In a whole what I did was to use the D3 force graph simulation on one hand to keep the force graph simulation and I let PixiJS handle all the rendering on top of the canvas element.


Creating the Example App

I’m going to refactor a little bit the app I created in the “Creating a Force Graph using React and D3” post. That means that if you haven’t read it go ahead and do that before you continue reading this post.


First you will need to install PixiJS library. In command line run the following code to install both PixiJS and PixiJS Viewport, which will help us to support things like zoom in and out:

npm i pixi.js pixi-viewport

After the libraries are installed we are ready to proceed.


I’ll use the same ForceGraph component container I created in the previous post, but this time I’ll use the runForceGraphPixi function instead of runForceGraph. runForceGraphPixi will be responsible to create and run the new force graph.


Building the Force Graph Generator

The force graph generator will be a function that will be responsible to generate the graph. Here is the declaration of the function which gets the containing div, the data for links and nodes and a function to generate a node tooltip:

import * as d3 from "d3";
import * as PIXI from "pixi.js";
import { Viewport } from 'pixi-viewport';
import styles from "./forceGraph.module.css";

export function runForceGraphPixi(
    container,
    linksData,
    nodesData,
    nodeHoverTooltip
 ){  
     ...
}

You can see that I import both D3 and PixiJS and I use the same signature that I used in runForceGraph from the previous post. Now let’s implement the function.


The first lines of code will be to copy the data and to get the container’s width and height:

const links = linksData.map((d)=>Object.assign({},d));
const nodes = nodesData.map((d)=>Object.assign({},d));

const containerRect = container.getBoundingClientRect();
const height = containerRect.height;
const width = containerRect.width;
let dragged = false;

container.innerHTML="";

I also add a variable I’ll use later to control the nodes drag and drop and clean the container from it’s previously generated HTML content.


Then, let’s add a few helper functions:

const color = ()=>{return "#f0f8ff";};

// Add the tooltip element to the graph
const tooltip = document.querySelector("#graph-tooltip");
if(!tooltip){
    const tooltipDiv=document.createElement("div");
    tooltipDiv.classList.add(styles.tooltip);
    tooltipDiv.style.opacity="0";
    tooltipDiv.id="graph-tooltip";
    document.body.appendChild(tooltipDiv);
}

const div=d3.select("#graph-tooltip");

const addTooltip = (hoverTooltip,d,x,y)=>{
    div
        .transition()
        .duration(200)
        .style("opacity",0.9);
    div
      .html(hoverTooltip(d))
      .style("left",`${x}px`)
      .style("top",`${y-28}px`);
 };
 
 const removeTooltip=()=>{
     div.transition().duration(200).style("opacity",0);
 };
 
 const colorScale=(num)=>parseInt(color().slice(1),16);
 
 function onDragStart(evt){
     viewport.plugins.pause('drag');
     simulation.alphaTarget(0.3).restart();
     this.isDown=true;
     this.eventData=evt.data;
     this.alpha=0.5;
     this.dragging=true;
 }
 
 function onDragEnd(evt){
     evt.stopPropagation();
     if(!evt.active)simulation.alphaTarget(0);
     this.alpha=1;
     this.dragging=false;
     this.isOver=false;
     this.eventData=null;
     viewport.plugins.resume('drag');
}

function onDragMove(gfx){
    if (gfx.dragging){
    dragged=true;
    const newPosition = gfx.eventData.getLocalPosition(gfx.parent);
    this.x=newPosition.x;
    this.y=newPosition.y;}
}

The helper functions will help us to add the tooltip, to support the coloring of the nodes and also to create the drag and drop functionality.


Now we add the code that will create the nodes and their links and will simulate the force graph:

const app = new PIXI.Application({ 
    width, 
    height,
    antialias: !0,
    transparent: !0,
    resolution: 1});
    container.appendChild(app.view);
    
    // create viewport
    const viewport = new Viewport({
        screenWidth: width,
        screenHeight: height,
        worldWidth: width*4,
        worldHeight: height*4,
        passiveWheel: false,
        
        interaction: app.renderer.plugins.interaction
        
        // the interaction module is important for wheel to work 
        properly when renderer.view is placed or scaled
    });
    
    app.stage.addChild(viewport);
    
    // activate plugins
    viewport.drag().pinch().wheel().decelerate().clampZoom({minWidth: 
    width/4,minHeight: height/4});
    
    const simulation = d3.forceSimulation(nodes)
    .force("link",d3.forceLink(links)
    // This force provides links between nodes
        .id((d)=>d.id)
        // This sets the node id accessor to the specified function. 
        If not specified, will default to the index of a node.
        
        .distance(50)
    )
    .force("charge",d3.forceManyBody().strength(-500))
    // This adds repulsion (if it's negative) between nodes.
    
    .force("center",d3.forceCenter(width/2,height/2))
    .force("collision",d3.forceCollide().radius((d)=>d.radius)
                                .iterations(2))
    .velocityDecay(0.8);
    
    /*   
    Implementation   
    */
    
    let visualLinks = new PIXI.Graphics();
    viewport.addChild(visualLinks);
    
    nodes.forEach((node)=>{
        const boundDrag=onDragMove.bind(node);
        const { name, gender }=node;
        node.gfx = new  PIXI.Graphics();
        node.gfx.lineStyle(1,0xD3D3D3);
        node.gfx.beginFill(colorScale(node.id));
        node.gfx.drawCircle(0,0,24);
        node.gfx.endFill();
        node.gfx
            // events for click
            .on('click',(e)=>{
                if(!dragged)
                {
                    e.stopPropagation();
                }
                dragged=false;
             })
             
             .on('mousedown',onDragStart)
             // events for drag end
             .on('mouseup',onDragEnd)
             .on('mouseupoutside',onDragEnd)
             // events for drag move
             .on('mousemove',()=>boundDrag(node.gfx));
            
    viewport.addChild(node.gfx);
       
    node.gfx.interactive=true;
    node.gfx.buttonMode=true;
    
    // create hit area, needed for interactivity
    node.gfx.hitArea = new PIXI.Circle(0,0,24);
    
    // show tooltip when mouse is over node
    node.gfx.on('mouseover',(mouseData)=>{
        addTooltip(nodeHoverTooltip,
            { name },
            mouseData.data.originalEvent.pageX,
            mouseData.data.originalEvent.pageY
        );
    });
    
    // make circle half-transparent when mouse leaves
    node.gfx.on('mouseout',()=>{
        removeTooltip();
    });
    
    const text = new PIXI.Text(name,{
        fontSize: 12,
        fill: '#000'
    });
    text.anchor.set(0.5);
    text.resolution=2;
    node.gfx.addChild(text);

Pay attention that I add both a Pixi.Applicaiton and also d3.forceSimulation. The PixiJS application will be responsible for the graph rendering according to the force simulation that D3 exposes.


When the graph is ready we will add a few event handlers to handle what is going to happen when the tick is happening :

const ticked = ()=>{
    nodes.forEach((node)=>{
        let{ x, y, gfx }=node;
        gfx.position = new PIXI.Point(x,y);
    });
    
    for (let i=visualLinks.children.length-1;i>=0;i--){
        visualLinks.children[i].destroy();
    }
    
    visualLinks.clear();
    visualLinks.removeChildren();
    visualLinks.alpha=1;
    
    links.forEach((link)=>{
        let{ source, target, number }=link;
        visualLinks.lineStyle(2,0xD3D3D3);
        visualLinks.moveTo(source.x,source.y);
        visualLinks.lineTo(target.x,target.y);
    });
    
    visualLinks.endFill();
   }
   
   // Listen for tick events to render the nodes as they update in your Canvas or SVG.
   simulation.on("tick",ticked);

In the tick event we clean all the links and then redraw them on the canvas again.


Last but not least, we will return the destroy function that the graph container is going to use when it’s going to unmount the graph:

return{
    destroy: ()=>{
        simulation.stop();
        nodes.forEach((node)=>{
            node.gfx.clear();
        });
        visualLinks.clear();
    }
 };

The whole function source code:

import * as d3 from "d3";
import * as PIXI from "pixi.js";
import {Viewport} from 'pixi-viewport';
import styles from "./forceGraph.module.css";

export function runForceGraphPixi
(
    container,
    linksData,
    nodesData,
    nodeHoverTooltip
){
    const links = linksData.map((d)=>Object.assign({},d));
    const nodes = nodesData.map((d)=>Object.assign({},d));
    
    const containerRect = container.getBoundingClientRect();
    const height = containerRect.height;
    const width = containerRect.width;
    let dragged = false;
    
    container.innerHTML="";
    
    const color = ()=>{ return "#f0f8ff";};
    
    // Add the tooltip element to the graph
    const tooltip = document.querySelector("#graph-tooltip");
    if(!tooltip){
        const tooltipDiv = document.createElement("div");
        tooltipDiv.classList.add(styles.tooltip);
        tooltipDiv.style.opacity="0";
        tooltipDiv.id="graph-tooltip";
        document.body.appendChild(tooltipDiv);
     }
     const div=d3.select("#graph-tooltip");
     
     const addTooltip=(hoverTooltip,d,x,y)=>{
         div
             .transition()
             .duration(200)
             .style("opacity",0.9);
         div
             .html(hoverTooltip(d))
             .style("left",`${x}px`)
             .style("top",`${y-28}px`);
      };
      
      const removeTooltip=()=>{
          div.transition().duration(200).style("opacity",0);
      };
      
      const colorScale = (num)=>parseInt(color().slice(1),16);
      
      function onDragStart(evt){
          viewport.plugins.pause('drag');
          simulation.alphaTarget(0.3).restart();
          this.isDown=true;
          this.eventData=evt.data;
          this.alpha=0.5;
          this.dragging=true;
      }
      
      function onDragEnd(evt){
          evt.stopPropagation();
          if (!evt.active)simulation.alphaTarget(0);
          this.alpha=1;this.dragging=false;
          this.isOver=false;
          this.eventData=null;
          viewport.plugins.resume('drag');
      }
      
      function onDragMove(gfx){
          if (gfx.dragging){
              dragged=true;
              const newPosition = gfx.eventData.getLocalPosition(gfx.parent);
              this.x=newPosition.x;
              this.y=newPosition.y;
          }
      }
      
      const app = new PIXI.Application({ width, height,antialias: !0,transparent: !0,resolution: 1});
      container.appendChild(app.view);
      
      // create viewport
      const viewport = new Viewport({
          screenWidth: width,
          screenHeight: height,
          worldWidth: width*4,
          worldHeight: height*4,
          passiveWheel: false,
          
          interaction: app.renderer.plugins.interaction
          // the interaction module is important for wheel to work properly when renderer.view is placed or scaled
      });
      
      app.stage.addChild(viewport);
      
      // activate plugins
      viewport.drag().pinch().wheel().decelerate().clampZoom({minWidth: width/4,minHeight: height/4});
      
      const simulation = d3.forceSimulation(nodes)
      .force("link",d3.forceLink(links)
      // This force provides links between nodes
      .id((d)=>d.id)
      // This sets the node id accessor to the specified function. If not specified, will default to the index of a node.
      .distance(50)
      )
      .force("charge",d3.forceManyBody().strength(-500))
      // This adds repulsion (if it's negative) between nodes.
      .force("center",d3.forceCenter(width/2,height/2))
      .force("collision",d3.forceCollide().radius((d)=>d.radius).iterations(2))
      .velocityDecay(0.8);
      /*   
      Implementation   
      */
      
      let visualLinks = new PIXI.Graphics();
      viewport.addChild(visualLinks);
      
      nodes.forEach((node)=>{
          const boundDrag=onDragMove.bind(node);
          const { name, gender }=node;
          node.gfx = new PIXI.Graphics();
          node.gfx.lineStyle(1,0xD3D3D3);
          node.gfx.beginFill(colorScale(node.id));
          node.gfx.drawCircle(0,0,24);
          node.gfx.endFill();
          node.gfx
              // events for click
              .on('click',(e)=>{
                  if(!dragged){
                      e.stopPropagation();
                  }
                  dragged=false;
             })
             .on('mousedown',onDragStart)
             // events for drag end
             .on('mouseup',onDragEnd)
             .on('mouseupoutside',onDragEnd)
             // events for drag move
             .on('mousemove',()=>boundDrag(node.gfx));
             
        viewport.addChild(node.gfx);
        
        node.gfx.interactive=true;
        node.gfx.buttonMode=true;
        
        // create hit area, needed for interactivity
        node.gfx.hitArea = new PIXI.Circle(0,0,24);
        
        // show tooltip when mouse is over node
        node.gfx.on('mouseover',(mouseData)=>{
            addTooltip(nodeHoverTooltip,
                { name },
                mouseData.data.originalEvent.pageX,
                mouseData.data.originalEvent.pageY
           );
       });
       
       // make circle half-transparent when mouse leaves
       node.gfx.on('mouseout',()=>{
           removeTooltip();
     });
     
     const text = new PIXI.Text(name,{
         fontSize: 12,
         fill: '#000'
    });
    text.anchor.set(0.5);
    text.resolution=2;
    node.gfx.addChild(text);
    });
    
    const ticked=()=>{
      nodes.forEach((node)=>{
          let { x, y, gfx }=node;
          gfx.position = new PIXI.Point(x,y);
      });
      
      for(let i=visualLinks.children.length-1;i>=0;i--){
          visualLinks.children[i].destroy();
      }
      
      visualLinks.clear();
      visualLinks.removeChildren();
      visualLinks.alpha=1;
      
      links.forEach((link)=>{
          let { source, target, number }=link;
          visualLinks.lineStyle(2,0xD3D3D3);
          visualLinks.moveTo(source.x,source.y);
          visualLinks.lineTo(target.x,target.y);
     });
     
     visualLinks.endFill();
 }
 
 // Listen for tick events to render the nodes as they update in your Canvas or SVG.
simulation.on("tick",ticked);

return {
    destroy: ()=>{
        simulation.stop();
        nodes.forEach((node)=>{
            node.gfx.clear();
        });
    visualLinks.clear


Now that everything is set in place you can run the app and look at your fancy force graph.


The Generated Force Graph


Summary

In the post, I showed you how to create a force graph component using React, D3 and PixiJS libraries.



Source: Medium, Gitconnected


The Tech Platform

0 comments
bottom of page