React + D3 Force Layout:新链接具有未定义的位置

React + D3 Force Layout: new Links have undefined positions

我相信我已经遵循了新 React Props 的一般更新模式。 D3 在接收到新的 props 时进行数据计算和渲染,因此 React 不必每次都渲染。

D3 适用于静态布局。但是当我在 shouldComponentUpdate(nextProps) 函数中收到新节点和链接时,节点缺少以下属性:

因此,所有新节点都有 <g tranform=translate(undefined, undefined)/> 并且聚集在左上角。

我更新道具的方式是将新对象推送到节点数组和链接数组。我不明白为什么 D3 不分配 d.x 和 d.y,就像它在 componentDidMount() 的初始设置中所做的那样。几天来我一直在为这个问题而苦苦挣扎。希望有人能帮我解决这个问题。

这里是ForceLayout.jsx:

//React for structure - D3 for data calculation - D3 for rendering

import React from 'react';
import * as d3 from 'd3';

export default class ForceLayout extends React.Component{
  constructor(props){
    super(props);
  }

  componentDidMount(){ //only find the ref graph after rendering
    const nodes = this.props.nodes;
    const links = this.props.links;
    const width = this.props.width;
    const height = this.props.height;

    this.simulation = d3.forceSimulation(nodes)
      .force("link", d3.forceLink(links).distance(50))
      .force("charge", d3.forceManyBody().strength(-120))
      .force('center', d3.forceCenter(width / 2, height / 2));

    this.graph = d3.select(this.refs.graph);

    this.svg = d3.select('svg');
    this.svg.call(d3.zoom().on(
      "zoom", () => {
        this.graph.attr("transform", d3.event.transform)
      })
    );

    var node = this.graph.selectAll('.node')
      .data(nodes)
      .enter()
      .append('g')
      .attr("class", "node")
      .call(enterNode);

    var link = this.graph.selectAll('.link')
      .data(links)
      .enter()
      .call(enterLink);

    this.simulation.on('tick', () => {
      this.graph.call(updateGraph);
    });
  }

  shouldComponentUpdate(nextProps){
    //only allow d3 to re-render if the nodes and links props are different
    if(nextProps.nodes !== this.props.nodes || nextProps.links !== this.props.links){
      console.log('should only appear when updating graph');

      this.simulation.stop();
      this.graph = d3.select(this.refs.graph);

      var d3Nodes = this.graph.selectAll('.node')
        .data(nextProps.nodes);
      d3Nodes
        .enter() 
        .append('g')
        .attr("class", "node")
      .call(enterNode);
      d3Nodes.exit().remove(); //get nodes to be removed
      // d3Nodes.call(updateNode);

      var d3Links = this.graph.selectAll('.link')
        .data(nextProps.links);
      d3Links
        .enter()
        .call(enterLink);
      d3Links.exit().remove();
      // d3Links.call(updateLink);

      const newNodes = nextProps.nodes.slice(); //originally Object.assign({}, nextProps.nodes)
      const newLinks = nextProps.links.slice(); //originally Object.assign({}, nextProps.links)
      this.simulation.nodes(newNodes);
      this.simulation.force("link").links(newLinks);

      this.simulation.alpha(1).restart();

      this.simulation.on('tick', () => {
        this.graph.call(updateGraph);
      });
    }
    return false;
  }

  render(){
    return(
      <svg
        width={this.props.width}
        height={this.props.height}
        style={this.props.style}>
        <g ref='graph' />
      </svg>
    );
    }
  }

  /** d3 functions to manipulate attributes **/
  var enterNode = (selection) => {
    selection.append('circle')
      .attr('r', 10)
      .style('fill', '#888888')
      .style('stroke', '#fff')
      .style('stroke-width', 1.5);

    selection.append("text")
      .attr("x", function(d){return 20}) //
      .attr("dy", ".35em") // vertically centre text regardless of font size
      .text(function(d) { return d.word });
   };

   var enterLink = (selection) => {
      selection.append('line')
        .attr("class", "link")
        .style('stroke', '#999999')
        .style('stroke-opacity', 0.6);
   };

   var updateNode = (selection) => {
       selection.attr("transform", (d) => "translate(" + d.x + "," + d.y + ")");
    };

    var updateLink = (selection) => {
      selection.attr("x1", (d) => d.source.x)
        .attr("y1", (d) => d.source.y)
        .attr("x2", (d) => d.target.x)
        .attr("y2", (d) => d.target.y);
    };

    var updateGraph = (selection) => {
       selection.selectAll('.node')
          .call(updateNode);
       selection.selectAll('.link')
          .call(updateLink);
    };

我尝试在 shouldComponentUpdate() 函数中将新节点推送到节点数组中,而不是修改服务器中的数组。但是新节点仍然出现在左上角,位置未定义。所以我猜我的问题出在 shouldComponentUpdate() 上。非常感谢任何帮助!!


编辑:发现 Object.assign(...) 不是 return 数组。改为 array.slice() 。现在所有节点都被渲染了一个位置但根本没有链接。旧节点也从原始位置磨损。

Here is how it looks when new props go in and shouldComponentUpdate is triggered

我不明白为什么链接上的位置与节点不对应。

forceLink 中的 links 默认使用对象引用指向 sourcetarget.

你没有展示你是如何构造你的 linksnodes 道具的,但是你可以通过调用 id 并设置一个 id 访问器来指向你的节点来绕过它逻辑 id,所以假设你的节点有一个 id 属性 这可以这样写:

.force("link", d3.forceLink(links).id(d => d.id).distance(50))

或者,您可以使用节点的索引作为访问器:

.force("link", d3.forceLink(links).id(d => d.index).distance(50))

.force("link", d3.forceLink(links).id((d, i) => i).distance(50))

-- 编辑--

另一个有用的措施是将当前节点的属性与新节点合并,这将使它们保留位置:

const updatePositions = (newNodes = [], currentNodes = []) => {
  const positionMap = currentNodes.reduce((result, node) => {
    result[node.id] = {
      x: node.x,
      y: node.y,
    };
    return result
  }, {});
  return newNodes.map(node => ({...node, ...positionMap[node.id]}))
}

然后在你的 shouldComponentUpdate 中(注意,这不是这段代码应该存放的地方)你可以这样称呼它:

var nodes = updatePositions(newProps.nodes, this.simulation.nodes())

并使用 nodes 而不是 newNodes

请注意,此代码假定节点具有唯一 ID 属性。更改它以适合您的用例

您还应该尝试在您的选择中添加一个 key 函数来识别您的节点和链接,例如:

this.graph.selectAll('.node')
    .data(nextProps.nodes, d => d.id) // again assuming id property 

我解决了我的问题,我发现它是错误的组合:

  1. 节点没有 link,或者 link 与节点不对应。

首先,我的 d3 图和数据有不同的方式来识别节点 - 当我的 link 数据指向对象时,图寻找索引为 links。我通过将两者都更改为 id(即查找字符串)解决了这个不匹配问题。 @thedude 建议的 link 为我指出了正确的方法。解决此问题导致新节点 link 正确组合在一起。

  1. 旧的节点仍然分离,反弹得更远,旧的 links 留在它们最初渲染的地方。

我怀疑这是从 d3 获取图形数据引起的,d3 定义了 x、y、vx、vy 和 index 属性。所以我所做的就是在更新数据之前在服务器中收到 currentGraph 时摆脱它们。

removeD3Extras: function(currentGraph) {
  currentGraph.nodes.forEach(function(d){
    delete d.index;
    delete d.x;
    delete d.y;
    delete d.vx;
    delete d.vy;
  });

  currentGraph.links.forEach(function(d){
    d.source = d.source.id;
    d.target = d.target.id;
    delete d.index;
  });

  return currentGraph;
}

成功了!现在它的行为与我打算的一样,在控制台上没有错误,但是我缩放、单击和拖动。

但还有改进的余地:

  • link在节点之上

  • 有时节点在 tick 期间彼此重叠,因此需要拖放。