Search code examples
typescriptd3.jsnuxt3.js

Dynamically updating force-directed graphs using d3JS


I am currently working on a Force-directed graph with d3JS. I am fairly new to d3 so I have based my (buggy) solution on tutorials and documentation.

Simply put I have an endpoint that supplies me with an object of node & link lists (in my code its a prop) periodically (every 5 seconds) and whenever I try to update my graph, I end up redrawing the entire graph on top of the old one.

Here is what I would like to achieve: A graph that dynamically adds, deletes & updates the graph without:

  1. redrawing the entire graph
  2. applying some crazy forces on the nodes making them move around, even after the simulation has finished. (the simulation should just apply to the updated nodes)

I am using NuxtJS v3 as my primary framework, although I think that's not too relevant.

Here is my implementation so far:

<template>
  <div id="graph">
    <svg id="graph-svg" width="960" height="600"></svg>
  </div>
</template>

I access the svg using its id and use d3 to display the graph:

<script setup lang="ts">
import { ref,watch } from 'vue';
import * as d3 from 'd3';

const props = defineProps({
  data: {
    type: Object,
    required: true,
  },
});
const { data } = props;
const metaData = ref();
let nodes, edges, simulation, svg;
let link, node;

// Watch for changes in the data prop
watch(() => props.data, async (newVal) => {
  if (newVal) {
    // Update the metadata and nodes/edges arrays
    metaData.value = props.data.graphStatistics;
    nodes = Object.values(props.data.nodes);
    edges = Object.values(props.data.edges);

    // Initialize the graph
    await initGraph();
  }
}, { immediate: true });


// Initialize the graph
const initGraph = async () => {
  // Select the SVG element and set its dimensions
  svg = d3.select("#graph-svg")
      .attr("width", window.innerWidth)
      .attr("height", window.innerHeight);

  // Create the force simulation
  simulation = d3.forceSimulation(nodes)
      .force("link", d3.forceLink(edges).id((d: { id: any; }) => d.id).distance(300).strength(1))
      .force("charge", d3.forceManyBody().strength(-7000))
      .force("center", d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2))
      .force("x", d3.forceX().strength(0.5))
      .force("y", d3.forceY().strength(0.5))
      .force("collide", d3.forceCollide(10));

  // Append the links
  link = svg.append("g")
      .attr("stroke", "#999")
      .attr("stroke-opacity", 0.6)
      .selectAll("line")
      .data(edges, (d) => d.id)
      .join("line")
      .attr("stroke-width", 1);

  // Append the nodes
  node = svg.append("g")
      .attr("stroke", "#fff")
      .attr("stroke-width", 1.5)
      .selectAll("circle")
      .data(nodes, (d) => d.id)
      .join("circle")
      .attr("r", 10)
      .attr("fill", "#537B87");

  // Update the positions of the nodes and links on each tick of the simulation
  simulation.on("tick", () => {
    link
        .attr("x1", d => d.source.x)
        .attr("y1", d => d.source.y)
        .attr("x2", d => d.target.x)
        .attr("y2", d => d.target.y);

    node
        .attr("cx", d => d.x)
        .attr("cy", d => d.y);
  });
};

</script>

It should also be noted that atm when the graph is redrawn it is instantiated in the top-left & not in the center.

Thanks in advance, I have completely given up & asking on here is my last option. I have been working on this for about a week now and still cannot figure it out. The other SO posts have brought me nowhere since they do not achieve a smooth updated graph.

Thanks again!!


Solution

  • So after some time I found a solution. I'm quite happy with the result & hopefully it can help some other person who is desperately trying to find a solution.

    //initialize this at onMounted, no data is needed
    initChart() {
         this.svg= d3.select(this.$refs.svg)
            .attr("viewBox", [-window.innerWidth / 2, -window.innerHeight / 2, window.innerWidth, window.innerHeight])//this will center the graph
            .attr("width", window.innerWidth)
            .attr("height", window.innerHeight);
    
         this.link = this.svg.append("g")
            .attr(...)//your styling
            .selectAll("line");
         this.node = this.chart.append("g")
            .attr(...)//your styling
            .selectAll("circle");
    }
    
    //create a method for the simulation ticks
    ticked() {
         this.link
            .attr("x1", d => d.source.x)
            .attr("y1", d => d.source.y)
            .attr("x2", d => d.target.x)
            .attr("y2", d => d.target.y);
    
          this.node
            .attr("cx", d => d.x)
            .attr("cy", d => d.y);
    }
    
    //here is the ACTUAL updating
    updateChart({nodes, edges}) {
          // now this is probably the most important part, and comes directly from mike bostocks implementation
          // Make a shallow copy to protect against mutation, while
          // recycling old nodes to preserve position and velocity.
          const old = new Map(this.node.data().map(d => [d.id, d]));
          nodes = nodes.map(d => ({...old.get(d.id), ...d}));
          edges = edges.map(d => ({...d}));
    
          this.link = this.link
            .data(edges, d => [d.source, d.target])
            .join(enter => enter.insert("line", "circle")
              .attr(...))//your styling
         );
    
          this.node = this.node
            .data(nodes, d => d.id)
            .join(enter => enter.append("circle")
              .attr()//your styling
              .call(node => node.append("title")// this is a tooltip
                .text(d => d.name))
            );
    
         // this is also very important as it restarts the simulation so the graph actually looks like it is being updated in real time
          this.simulation.nodes(nodes);
          this.simulation.force("link").links(edges);
          this.simulation.alpha(1).restart().tick();
          this.ticked();
        },
    

    I hope this helps!