close button
Leveling up your D3 Network Graphs: From Simple Canvas to Interactive Powerhouse
profile picture Deeksha
16 min read Dec 8, 2025

Leveling up your D3 Network Graphs: From Simple Canvas to Interactive Powerhouse

In Part one of this blog, we built a simple network graph using React and D3, as we hinted at the end of our previous blog, we will be exploring adding interactivity to it with features like dragging, zooming and highlighting. In this blog we will be building on top of the simple Canvas graph and implementing key interactive elements to create a truly engaging user experience. So let’s get started!

Code reference - See the full implementation here (We recommend you have the code open on the side while reading this blog for better understanding)

Setting Up Interactive Behavior

Let's understand the key components that make the interactivity possible.

  • currentTransform - For Zoom and pan
    Imagine you're looking at a map. When you zoom in or pan around, you need to remember where you are and how zoomed in you are. That's exactly what currentTransform does, it tracks zoom and pan state, it creates a mutable container, which is initialised to the default “no zoom or no pan” state specifically. This allows the render() to always access the latest transform state efficiently.

  • hoveredNodeRef: For hover effect on the nodes
    hoveredNodeRef tracks the current hovered node, ref is used to keep track of which node object the user’s mouse pointer is currently hovering over. Initially, the state is null because when the component first renders, the user isn’t hovering over any node yet.

Building an Optimized Node Relationship Map

Let’s untangle the complexity by breaking down the elements one by one.


To connect the nodes with each other we have nodeConnectionsMap that tracks all the relationships in our network; it knows exactly how nodes are connected to each other. This object is memoised so that React only recalculates when there is change in the value of nodes or links. This improves performance by avoiding unnecessary recalculations on every render.

const nodeConnectionsMap = useMemo(() => {
   const connectionsMap: Record<string, Set<string>> = {};
  
   nodes.forEach(node => {
     connectionsMap[node.id] = new Set();
   });
  
   // Add connections based on links
   links.forEach(link => {
     const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
     const targetId = typeof link.target === 'object' ? link.target.id : link.target;
    
     if (connectionsMap[sourceId]) {
       connectionsMap[sourceId].add(targetId);
     }
     if (connectionsMap[targetId]) {
       connectionsMap[targetId].add(sourceId);
     }
   });
  
   return connectionsMap;
 }, [nodes, links]);



We start by creating a space for each node, then systematically map out how they're connected.

For each connection, we record it in both directions - if Node A connects to Node B, we note this in both nodes' profiles. We handle D3's flexible ID format by normalizing everything to string IDs, ensuring consistent labeling throughout.

This bidirectional mapping lets us instantly trace relationships from any starting point, making it easy to highlight connected nodes during user interaction. Thanks to useMemo we only calculate these relationships when the network structure changes, giving us a fast, efficient way to look up any connection in our network.

We create an empty object called connectionsMap and store each node's directly connected neighbors.

Then we populate it in two phases:

First, we loop through every node and give it an empty Set, which will store all the nodes that are connected to it directly.
Next, we loop through each link and extract the source and target node IDs.
(since D3 sometimes converts these to objects, we add checks to make sure we normalize them to string IDs). Then we check for source and target IDs, if present, then we update the connection map in both directions, making sure we record bidirectional links.

So connectionsMap has access to who's connected to whom.

Graph Initialization and Interactivity

Now let’s prepare our drawing surface by getting the 2D drawing context from the Canvas and setting the width and height as per our choice.


In our previous blog, as you might have noticed, the image was not crisp, and the label was not sharper; that is because of how Canvas handles it. To solve this problem, we need to understand the key difference between SVG and Canvas.

SVG handles high-resolution screens by default, while Canvas needs manual scaling. SVG is vector-based and not pixel-based like Canvas.

So what this means is that while using SVG we don't have to worry about the device’s pixel ratio, the browser automatically renders vector graphics as crisp and sharp and text in SVG is treated like normal HTML text so it's automatically rendered clearly with the system’s font rendering engine.

As a result, even on Retina or high-DPI screens, SVG nodes, labels, and icons remain sharp. In the case of Canvas, as it has a fixed number of pixels, we have to scale it manually to match the device’s pixel ratio (window.devicePixelRatio) or else the image will appear blurry, the same goes for texts as well. We use context.scale() to scale the drawing accordingly.

How DPR helps scale Canvas Rendering

We create a variable called dpr and use window.devicePixelRatio to detect how many physical pixels on the device's screen are used to display one logical (CSS) pixel, we use this information to store the device pixel ratio (DPR) of the current screen. In case the browser doesn't support devicePixelRatio, it will default to 1 (standard resolution). The context.scale (dpr, dpr) call then ensures that all our drawing operations are properly scaled to match this higher resolution.


We start by collecting all nodes and node IDs that are connected by at least one link.


We have learnt about simulation in Building a simple Network Graph with React and D3, so we won’t be going into detail here.

Animating Connections

To bring data to motion, we need to make sure that the performance of the simulation is good when dealing with a large number of nodes.

To make sure we have smooth animation, we use a quadtree for spatial indexing, collision detection, and performance optimization.

A quadtree is a spatial data structure that helps partition 2D space into regions. In D3.js, a quadtree allows you to efficiently search for nearby points

.x((d) => d.x || 0)
.y((d) => d.y || 0)

This tells the quadtree how to access the x and y coordinates of a node. The quadtree needs x/y coordinates to correctly place and index the node spatially.

.addAll(connectedNodes)

This adds all the connectedNodes into the quadtree at their respective (x,y) positions.

Building a quadtree is useful to detect collisions and avoid nodes overlapping, to find the node under the cursor, and for quick interactions like drag, hover, or tooltips.
Without a quadtree, you'd need to loop through all nodes each time, and it’d be much slower for large datasets.

force("collision", d3.forceCollide().radius(50))

d3.forceCollide() adds a collision force that prevents nodes from overlapping .radius(50) gives each node a collision radius of 50 pixels.

When nodes get closer than 50 pixels, they push away from each other to maintain that distance. It gives nodes breathing room so they don’t clump up.

.force("x", d3.forceX(width / 2).strength(0.07))
.force("y", d3.forceY(height / 2).strength(0.07))

This pulls each node horizontally and vertically toward the center of the Canvas. This keeps the whole graph to the center.

.alphaDecay(0.005)


alphaDecay sets how quickly the simulation slows down. A lower number means longer animation/simulation. It controls how fast the layout stabilizes.

.velocityDecay(0.3)

velocityDecay sets the friction, how quickly nodes slow down over time. A higher value means stronger friction, which in turn makes the nodes stop moving faster.

 .on("tick", () => {
       quadtree.addAll(connectedNodes);
       needsUpdate = true;
     });


On every frame update, the quadtree updates with new node positions. Since node positions keep changing, the quadtree needs to stay in sync.

needsUpdate = true;

This signals that the Canvas needs to be redrawn with new positions.

Drag functionality

Let’s create a function to implement the drag functionality. We will make use of the drag function from the d3 library and store the drag behaviour in a variable.

.container(() => canvas)


First, we set the container element for the drag gestures.

.filter(event => !event.ctrlKey && !event.button)


This tells d3 to allow dragging if the user is not holding the Ctrl key or the right-click mouse button.

.subject((event) => {....}


This defines the data that should be associated with a drag gesture.

const point = getMousePosition(event);

getMousePosition is used to get the actual graph-space coordinates under the mouse.

If your Canvas has been zoomed or panned, then the mouse's position on the screen is not the same as the actual position in your graph layout, so when we are trying to get the coordinates, we need to account for the current zoom scale and pan offset.


Using getMousePosition, we get the raw mouse event coordinates that are relative to the zoomed or panned screen.

const result = findNodeAtPosition(point);


findNodeAtPosition searches for a node near the given “point” using a quadtree.

const { node, isConnected } = result;
      
       if (isConnected) {
         return { x: node.x, y: node.y, data: node, isConnected };
       } else {
         const pos = unconnectedPositions.current.get(node.id);
         if (!pos) return;
         return { x: pos.x, y: pos.y, data: node, isConnected };
       }

Here we destructure the found node and check whether it’s connected. If connected, return the current coordinates and store the node as data. If unconnected, look up manually stored positions from unconnectedPositions and return them so dragging still works for these nodes.

.on("start", (event) => {
       if (!event.subject) return;
      
       if (event.subject.isConnected) {
         if (!event.active) simulation.alphaTarget(0.3);
         const d = event.subject.data;
         d.fx = event.x;
         d.fy = event.y;
       } else {
         const pos = unconnectedPositions.current.get(event.subject.data.id);
         if (pos) {
           pos.fx = event.x;
           pos.fy = event.y;
         }
       }
      
       canvas.style.cursor = "grabbing";
     })

.on(“start”) runs when the user starts dragging a node. Inside the function, it checks if the node is already being dragged; if so, then it skips. If the node is connected, then the simulation is initiated to slightly react to the dragging event and sets the node’s fixed position to where the mouse is being dragged. For unconnected nodes, update their manually tracked fixed positions. When the dragging starts, the style of the cursor is changed to a “grabbing” gesture ( ✊).

  .on("drag", (event) => {
       if (!event.subject) return;
      
       if (event.subject.isConnected) {
         const d = event.subject.data;
         d.fx = event.x;
         d.fy = event.y;
       } else {
         const pos = unconnectedPositions.current.get(event.subject.data.id);
         if (pos) {
           pos.x = event.x;
           pos.y = event.y;
           pos.fx = event.x;
           pos.fy = event.y;
         }
       }
       needsUpdate = true;
     })


Now we handle what happens when the node is being dragged.


If there’s no node being dragged, we exit early; this is a safety check to avoid errors.

event.subject.data gives us the node's data, and fx and fy are D3's fixed position properties. If it’s connected, it drags and pins this node exactly to where the mouse is.

If the node is unconnected, then it takes the unconnected node’s position from unconnectedPositions.current and updates its position.

Meanwhile, it moves it along with the mouse while dragging. We store the new position manually because unconnected nodes are not managed by D3 simulation directly like connected nodes.

needsUpdate = true;

Mark that the Canvas needs to be redrawn because something (node position) changed.

.on("end", (event) => {
       if (!event.subject) return;
      
       if (event.subject.isConnected) {
         if (!event.active) simulation.alphaTarget(0);
         // Connected nodes keep their position
       } else {
         const pos = unconnectedPositions.current.get(event.subject.data.id);
         if (pos) {
           // Keep the fixed position for unconnected nodes
           pos.x = event.x;
           pos.y = event.y;
           pos.fx = event.x;
           pos.fy = event.y;
         }
       }
      
       canvas.style.cursor = "default";
       needsUpdate = true;

First, we do a safety check by checking if no node was actually dragged; if true, then we exit early. Then we check if the node was connected to the main network, event.active is a D3 internal counter that tells how many drags are still happening. After checking if no drag is happening, we set the alphaTarget(0), which slowly cools the simulation and lets the graph settle.

After that, we find the unconnected node’s saved position and update it manually. This ensures that after dropping, the unconnected node stays exactly where you left it. After dragging ends,the mouse cursor is changed back to its normal gesture (✋).

 d3.select(canvas).call(dragBehavior)

Here, d3 selects the Canvas element and attaches the drag behaviour to it.

 canvas.addEventListener("mousemove", (event) => {
     const pos = getMousePosition(event);
     // Uses the optimized findNodeAtPosition
     const result = findNodeAtPosition(pos);
     const node = result ? result.node : null;
    
     if (node !== hoveredNodeRef.current) {
       hoveredNodeRef.current = node || null;
       needsUpdate = true;
       canvas.style.cursor = node ? "pointer" : "default";
     }
   });

Whenever the mouse moves over the Canvas, an event listener listens for mouse move events and helps in enhancing interactivity:

  • The graph calculates the mouse’s current coordinates relative to the Canvas and automatically adjusts for any zoom or pan applied.

  • It uses findNodeAtPosition function checks using a spatial index (quadtree), if the mouse is close enough to any node.

  • If the hovered node has changed since the last event (to avoid redundant renders), it updates a reference to the newly hovered node (or sets it to null if none are nearby).

  • The cursor instantly changes to a pointer when a node is hovered, signaling interactivity, and returns to default otherwise. This improves UX, the user feels "something is interactive" when hovering a node and makes the network feel responsive.

     function render() {
     if (needsUpdate && context) {
       needsUpdate = false;
       context.save();
       context.clearRect(0, 0, width, height);
       context.translate(currentTransform.current.x, currentTransform.current.y);
       context.scale(currentTransform.current.k, currentTransform.current.k);
    
       const hoveredNodeId = hoveredNodeRef.current ? hoveredNodeRef.current.id : null;
      
       // Draw links
       links.forEach((link) => {
         const source = link.source as any;
         const target = link.target as any;
         const sourceId = typeof source === 'object' ? source.id : source;
         const targetId = typeof target === 'object' ? target.id : target;
        
         const isHighlighted = hoveredNodeId && (
           sourceId === hoveredNodeId ||
           targetId === hoveredNodeId
         );
    
         const dx = target.x - source.x;
         const dy = target.y - source.y;
         const dr = Math.sqrt(dx * dx + dy * dy);
    
         const curveOffset = 20;
         const midX = (source.x + target.x) / 2;
         const midY = (source.y + target.y) / 2;
        
         const normalX = -dy / dr * curveOffset;
         const normalY = dx / dr * curveOffset;
         const controlX = midX + normalX;
         const controlY = midY + normalY;
    
         // Set opacity based on hover state
         const opacity = hoveredNodeId && !isHighlighted ? 0.15 : 1;
        
         // Draw curved path
         context.beginPath();
         context.moveTo(source.x, source.y);
         context.quadraticCurveTo(controlX, controlY, target.x, target.y);
        
         // Highlight connections to hovered node
         const linkColor = isHighlighted ? "#100" : "#999";
         context.strokeStyle = hoveredNodeId
           ? isHighlighted ? linkColor : `rgba(153, 153, 153, ${opacity})`
           : linkColor;
         context.lineWidth = isHighlighted ? 2 : 1.5;
         context.stroke();
    
         // Arrowhead
         const angle = Math.atan2(target.y - controlY, target.x - controlX);
         const arrowLength = 25;
         context.beginPath();
         context.moveTo(target.x, target.y);
         context.lineTo(
           target.x - arrowLength * Math.cos(angle - Math.PI / 10),
           target.y - arrowLength * Math.sin(angle - Math.PI / 10)
         );
         context.lineTo(
           target.x - arrowLength * Math.cos(angle + Math.PI / 10),
           target.y - arrowLength * Math.sin(angle + Math.PI / 10)
         );
         context.closePath();
         context.fillStyle = hoveredNodeId
           ? isHighlighted ? linkColor : `rgba(153, 153, 153, ${opacity})`
           : linkColor;
         context.fill();
    
         // Score label
         const labelPosition = 0.6;
         const labelX = source.x + (controlX - source.x) * labelPosition;
         const labelY = source.y + (controlY - source.y) * labelPosition;
        
         context.textBaseline = "middle";
         context.textAlign = "center";
         context.imageSmoothingEnabled = true;
         context.imageSmoothingQuality = "high";
         context.font = "12px sans-serif";
         context.fillStyle = hoveredNodeId
           ? isHighlighted ? "#000" : `rgba(255, 255, 255, ${opacity})`
           : "#000";
         const linkKey = `${sourceId}-${targetId}`;
         const averageScore = averageScoresByLink[linkKey];
         context.fillText(averageScore ? averageScore.toFixed(1) : "", labelX, labelY);
       });
    
       // Draw connected nodes
       connectedNodes.forEach((node) => {
         const isHovered = hoveredNodeRef.current && node.id === hoveredNodeRef.current.id;
         const isConnectedToHovered = hoveredNodeRef.current &&
           nodeConnectionsMap[hoveredNodeRef.current.id]?.has(node.id);
        
         // Highlight the node if it's hovered or connected to the hovered node
         const shouldHighlight = isHovered || isConnectedToHovered;
         // Determine opacity based on hover state
         const opacity = hoveredNodeId && !shouldHighlight ? 0.2 : 1;
        
         context.beginPath();
         context.arc(node.x!, node.y!, shouldHighlight ? 18 : 15, 0, 2 * Math.PI);
        
         const nodeScore = nodeAverageScores[node.id];
         let fillColor = nodeScore !== undefined ? colorScale(nodeScore) : "#999";
        
         if (shouldHighlight) {
           context.shadowColor = "#ffffff";
           context.shadowBlur = 15;
           context.shadowOffsetX = 0;
           context.shadowOffsetY = 0;
           context.fillStyle = fillColor;
         } else {
           // Apply opacity for non-highlighted nodes when something is hovered
           if (hoveredNodeId) {
             // Create a semi-transparent version of the color
             const color = d3.color(fillColor);
             if (color) {
               color.opacity = opacity;
               context.fillStyle = color.toString();
             } else {
               context.fillStyle = `rgba(153, 153, 153, ${opacity})`;
             }
           } else {
             context.fillStyle = fillColor;
           }
         }
         context.fill();
        
         // Reset shadow
         context.shadowColor = "transparent";
         context.shadowBlur = 0;
        
         // Applies opacity to text for non-highlighted nodes
         const labelColor = shouldHighlight ? "#010" : "#000";
         context.fillStyle = hoveredNodeId && !shouldHighlight
           ? `rgba(255, 255, 255, ${opacity})`
           : labelColor;
         context.font = shouldHighlight ? "bold 18px sans-serif" : "18px sans-serif";
         context.fillText(node.id, node.x! + 12, node.y! + 18);
       });
    
       context.restore();
     }
     animationFrameId = requestAnimationFrame(render);
     }
    

The render function is the heart of the dynamic graph

  • It updates when something like node position or hover state really changes, thanks to the needsUpdate flag.
  • It saves the current Canvas state, clears the drawing area, and reapplies the latest pan & zoom transforms for accurate placement.
  • To represent selective highlighting, it checks if either end of a link is the currently hovered node. Highlighted links appear bolder, and less prominent ones are dimmed for clarity. It draws curved lines for connections, adds directional arrowheads, and paints score labels along each link for extra insight. Each node is evaluated if hovered or directly connected to the hovered node, it gets accentuated with a glow and brighter color. Other nodes are toned down for visual emphasis without clutter.
  • Once rendering is complete, the function restores the Canvas state and requests the next animation frame, keeping animation and interaction fluid and in sync with user actions.
  • The graph employs a smooth zoom behavior that lets users scale from a tiny overview to close-up details, all while maintaining rendering speed and clarity.
  • When the component unmounts, it gracefully stops animations, disconnects events, and cleans up D3 behaviors to avoid memory leaks.

This ensures that the network graph remains crisp, interactive, and performant, while delivering a delightful user experience that scales with both data size and viewer curiosity.

Conclusion

By layering interactivity such as zooming, dragging, and dynamic highlighting onto a D3 and React Canvas network graph, the graph is a more user-friendly tool for data exploration. This approach not only enhances usability but also demonstrates how combining D3’s simulation engine with React’s component model can yield scalable and maintainable visualizations.

Application Modernization Icon

Explore limitless possibilities with AntStack's frontend development capabilities. Empowering your business to achieve your most audacious goals. Build better.

Talk to us

Tags

Your Digital Journey deserves a great story.

Build one with us.

Recommended Blogs

Cookies Icon

These cookies are used to collect information about how you interact with this website and allow us to remember you. We use this information to improve and customize your browsing experience, as well as for analytics.

If you decline, your information won’t be tracked when you visit this website. A single cookie will be used in your browser to remember your preference.