vasturiano / react-force-graph

React component for 2D, 3D, VR and AR force directed graphs
https://vasturiano.github.io/react-force-graph/example/large-graph/
MIT License
2.19k stars 283 forks source link

Unexpected nodes moving on update #229

Closed tinchoz49 closed 3 years ago

tinchoz49 commented 4 years ago

Describe the bug

I have a graph where I'm just updating the state with the same referenced nodes and during this update where nothing change I noticed miminal moves between the nodes, like a "heartbeat" on every update.

To Reproduce

Try the code below:

<head>
  <style> body { margin: 0; } </style>

  <script src="//unpkg.com/react@16/umd/react.production.min.js"></script>
  <script src="//unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
  <script src="//unpkg.com/babel-standalone"></script>

  <script src="//unpkg.com/react-force-graph-2d"></script>
  <script src="//unpkg.com/d3"></script>
  <!--<script src="../../src/packages/react-force-graph-3d/dist/react-force-graph-3d.js"></script>-->
</head>

<body>
  <div id="graph"></div>

  <script type="text/jsx">
    const { useState, useEffect } = React;

    const DynamicGraph = () => {
      const [data, setData] = useState({ 
        nodes: [{ id: 0, fx: 0, fy: 0 }, {id: 1}, {id: 2}], 
        links: [{ source: 0, target: 1 }, { source: 0, target: 2 }, { source: 1, target: 2 }] 
      });

      useEffect(() => {
        setInterval(() => {
          setData(({ nodes, links }) => {
            return { nodes, links };
          });
        }, 1000);
      }, []);

      return <ForceGraph2D
        enableNodeDrag={false}
        graphData={data}
      />;
    };

    ReactDOM.render(
      <DynamicGraph />,
      document.getElementById('graph')
    );
  </script>
</body>

Expected behavior If the nodes are the same I would like to have the graph freeze, I mean, without doing minimal moves.

Screenshots

Peek 2020-10-06 15-29

Desktop (please complete the following information):

atombender commented 4 years ago

Having the same problem. This is an issue with force-graph.

vasturiano commented 3 years ago

@tinchoz49 thanks for reaching out.

The issue here is that the value of graphData changes at every tick of your setInterval, because you're creating a new object every time. This in turn instructs React that the prop has changed and the inner force graph component should be updated with new data, triggering a re-heat of the force simulation.

The solution is to memoize your data object so that the reference doesn't change at every render.

This is not an issue with force-graph but rather how React triggers re-renders based on prop value changes.

atombender commented 3 years ago

@vasturiano The simulation shouldn't "re-heat" even if you set the same data.

There are of course lots of use cases the underlying data changes but the set of nodes and edges remains the same. For example, I may have some underlying data that I use to specify the visual size or colour of each graph node. Those things may change, but shouldn't cause the whole graph to spasm. (Changing the size should of course cause the tree to re-animate, although I would expect this to be tweened and not cause sudden spasms.)

As an aside, the fact that this library uses mutable data doesn't really fit React. It took me a few hours before noticed that the component would add position and velocity to my prop, which feels really wrong to me. That is internal state, and my own data is supposed to be immutable.

vasturiano commented 3 years ago

@atombender please note that if you pass a new object reference to graphData at every render, the component does not know that it is the same data and can only assume that it's a new data set. The way to solve this is to memoize the input prop so its value doesn't change. This is a common React pattern.

Regarding why the simulation reheats, this happens when data changes are detected. The rationale behind it is that because the data is different the graph needs to find a new balance and adjust to the changes. The way to do that is to re-heat the simulation. If you find the reheating settings too drastic for your use case, there's multiple parameters you can manipulate to calibrate this to your needs: d3AlphaDecay, d3VelocityDecay, d3AlphaMin, cooldownTicks, warmupTicks. Tweening is not applicable as the simulation ticking is tied into the frame engine, so you have new positions at every frame.

The fact that position and velocity attributes are bound to the node objects is a pattern adopted from d3-force (and in fact does not occur if you're using the ngraph engine).

tinchoz49 commented 3 years ago

Hey @vasturiano, thank you for your help. You right, it checks the same reference of the data object, I thought that by just keeping the same nodes object reference would be enough that's why I didn't understand then.

For example changing the code like below works as expected:

useEffect(() => {
        setInterval(() => {
          setData((oldState) => {
            return oldState;
          });
        }, 1000);
      }, []);