d3力导向图实现拓扑图

899 阅读3分钟

我正在参加「掘金·启航计划」 最近项目上有个功能,拓扑图,最开始用antV做了一版初版,但是被老大驳回,说不太好,然后就重新用d3做了一版,基于老版拓扑的结构和一些基础知识。整个组件当然也不是我独立完成,有靠于老大的润色和小伙伴的奠基。从这个过程学到很多关于d3的基础知识。 这是整个的结构 在这里插入图片描述 在这里插入图片描述

从index开始说 参考d3 的几种模型说明,使用力模型应该是最合适的。 在这里插入图片描述 详情可以参考github.com/d3/d3/wiki/… 直接放代码吧,我懒得拆分了。基本上的备注都写了

	const nodeRefs = useRef(`topology_n_${uuid()}`);
  const linkRefs = useRef(`topology_l_${uuid()}`);
  const linkNodeRefs = useRef(`topology_l_n__${uuid()}`);

  // 深拷贝并存储这个值
  const dataClone = useCacheData(tData);

  // d3 力模型
  useEffect(() => {
    const { nodes, calls } = dataClone;

    if (!nodes) return;
    try {
      const nodesDom = D3
        .selectAll(`.${nodeRefs.current}`)
        .data(nodes);
      const linksDom = D3.selectAll(`.${linkRefs.current}`).data(calls);
      const linkNodeDom = D3.selectAll(`.${linkNodeRefs.current}`).data(calls);

      const tick = () => {
        nodesDom.attr('transform', (d) => `translate(${d.x - 25}, ${d.y - 25})`);
        linksDom.attr('d', (d) => {
          const { source, target } = d;
          const x1 = source.x;
          const y1 = source.y;
          const x2 = target.x;
          const y2 = target.y;
          const distance = Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2)) || 1;
          const startDistance = 30;
          const endDistance = 30;
          // 利用相似三角行求出距离(x1, y1)、(x2, y2)一定距离的(x3, y3)、(x4, y4)
          const x3 = (x2 - x1) * (startDistance / distance) + x1;
          const y3 = (y2 - y1) * (startDistance / distance) + y1;
          const x4 = (x2 - x1) * ((distance - endDistance) / distance) + x1;
          const y4 = (y2 - y1) * ((distance - endDistance) / distance) + y1;
          // 三角形三边分别是(x3,y3),(x4,y4),(x3,y4),取得三角形重心点坐标,作为控制点
          // const x5 = (x4 + x3 + x3) / 3;
          // const y5 = (y4 + y3 + y4) / 3;
          // `M${x3} ${y3} Q ${x5} ${y5} ${x4} ${y4}` //二次贝塞尔曲线,重心离曲线不远,所以双向曲线时间距太近,如果是直线两个点还是会重合
          // 选用圆弧线
          const r = Math.sqrt((x4 - x3) * (x4 - x3) + (y4 - y3) * (y4 - y3)) * 2;
          const properties = new SvgPathProperties(`M${x3} ${y3} A ${r} ${r} 0 0 1 ${x4} ${y4}`);
          const length = properties.getTotalLength();
          const point = properties.getPointAtLength(length / 2);

          const a = linkNodeDom
            ?._groups[0]
            ?.find(
              (val) => (
                val?.__data__?.target?.id === target?.id
                && val?.__data__?.source?.id === source?.id
              ),
            );

          a?.setAttribute(
            'transform',
            `translate(${point.x} ${point.y})`,
          );

          return `M${x3} ${y3} A ${r} ${r} 0 0 1 ${x4} ${y4}`;
          // return `M${x3} ${y3} Q ${(x4 + x3) / 2} ${(y4 + y3) / 2 - 20} ${x4} ${y4}`;
        });
      };

      instanceRef.current = D3.forceSimulation(nodes)//力模型绑定节点
	  .force('link',
	    D3
	      .forceLink(calls)//绑定连线
	      .id((d) => d.id)
	      .distance(150))//添加一个距离
	  .force('yPos', D3.forceY().strength(0.5))//在y轴上施加一个力,让他压扁一点
	  .force('charge', D3.forceManyBody().strength())//创建一个名为change的多体力
	  .force(
	    'collision',
	    D3.forceCollide().radius(() => 100),//创建一个名为collision的圆碰撞力,半径为100
	  )
	  .force('center', D3.forceCenter())//创建一个名为center的定心力
	  .on('tick', tick);//绑定tick事件
      //   .stop();

      // 暂时没看出来加了没加有什么变化
      //   // 手动调用 tick 使布局达到稳定状态
      // D3.timeout(() => {
      //   const n = Math.ceil(Math.log(instanceRef.current.alphaMin())
      // / Math.log(1 - instanceRef.current.alphaDecay()));
      //   for (let i = 0; i < n; i += 1) {
      //     instanceRef.current.restart();
      //   }
      // });

      const onDragStart = (event) => {
        nodesDom._groups[0].forEach((e) => {
          e.__data__.fx = e.__data__.x;
          e.__data__.fy = e.__data__.y;
        });
        if (!event.active) {
          instanceRef.current
            .alphaTarget(0.01)
            .restart();
        }
        event.sourceEvent.stopPropagation();
      };

      const onDrag = (event, e) => {
        e.fx = event.x;
        e.fy = event.y;
      };

      const onDragEnd = () => {
        console.log('onDragEnd');
      };

      nodesDom.call(
        D3
          .drag()
          .on('start', onDragStart)
          .on('drag', onDrag)
          .on('end', onDragEnd),
      );
    } catch {
      message.error('数据异常');
      setError(true);
    }
  }, [dataClone, extra]);

  useEffect(() => {
    const svg = D3.select(svgRef.current);
    const transitionG = D3.select(transitionRef.current);

    const dimensions = svgRef.current.getBoundingClientRect();

    // d3 zoom
    const zoom = D3.zoom()
      .scaleExtent([0.5, 10])
      .on('zoom', (e) => {
        transitionG.attr('transform', e.transform);
      });

    // 居中
    svg.call(zoom.transform, D3.zoomIdentity.translate(dimensions.width / 2.5, dimensions.height / 2).scale(0.8));

    // zoom
    svg.call(zoom);
  }, [extra]);

  const { nodes = [], calls: _calls = [] } = tData;

  const calls = _calls
    .map((v) => ({
      ...v,
      id: uuid(),
    }));

  return (
    error ? (
      null
    ) : (
      <div className="topology-container" style={style}>
        <svg ref={svgRef} width="100%" height="100%">
          <defs>
            <marker
              id="mark-arrow"
              markerUnits="userSpaceOnUse"
              viewBox="0 -5 10 10"
              refX={8.4}
              refY={0}
              markerWidth={14}
              markerHeight={14}
              orient="auto"
              strokeWidth="2"
            >
              <path d="M2,0 L0,-3 L9,0 L0,3 M2,0 L0,-3" fill="#217EF28f" />
            </marker>
            <g id="tooltip">rect</g>
          </defs>
          <g ref={transitionRef}>
            <g>
              {
                calls.map((d) => (
                  <Link
                    key={d.id}
                    className={[
                      'topology-link',
                      linkRefs.current,
                      `${extra?.linkClassName?.(d)}`,
                    ]
                      ?.join(' ')}
                    href="/"
                    data={d}
                  />
                ))
              }
            </g>
            <g>
              {
                nodes.map((d) => (
                  <Node
                    key={d.id}
                    className={`topology-node ${nodeRefs.current}`}
                    data={d}
                    tooltips={extra?.nodeNameTip(d) ?? ''}
                    extra={extra}
                  />
                ))
              }
            </g>
            {
              extra?.linkTip && (
                <g>
                  {
                    calls.map((d) => (
                      <LinkNode
                        key={d.id}
                        className={`topology-link-node ${linkNodeRefs.current}`}
                        data={d}
                        tooltips={extra?.linkTip(d) ?? ''}
                      />
                    ))
                  }
                </g>
              )
            }
          </g>
        </svg>
      </div>
    )

  );
};

这里一直困扰的一个点是在线中心画圆点的问题,关键是计算问题。

最开始使用的是二次贝塞尔曲线,后面又尝试了椭圆的线,最后选择了圆弧线,相关的尝试都有放在注释中 其中困扰最大的就是线中间的点和线匹配不上的问题,总是差一些,最后发现,是自己吧线的计算和点的计算分开了,所以导致在线计算的时候,拿到的相关值和点计算拿到的相关值存在误差。

 const a = linkNodeDom
            ?._groups[0]
            ?.find(
              (val) => (
                val?.__data__?.target?.id === target?.id
                && val?.__data__?.source?.id === source?.id
              ),
            );

所以最后选择在线计算的时候计算点。 另外还有一个困扰点就是关于初始化的时候,topo图抖动了一会才稳定下来,遇到数据多的时候,那真是群魔乱舞,所以为此解决折腾了很久,主要是不了解他的原理,期间一直觉得是自己的力和一些距离设置的有问题,所以一直在这上面打转,后面发现是后面这段代码

D3.timeout(() => {
        const n = Math.ceil(Math.log(instanceRef.current.alphaMin())
          / Math.log(1 - instanceRef.current.alphaDecay()));

        for (let i = 0; i < n; i += 1) {
          instanceRef.current.restart().tick();
        }
      }, 0);

重点就是restart()之后执行tick,以前一直觉得stop之后执行restart就可以,或者再手动执行tick,没想到是要绑在一起执行。 更新一个bug: 两条线相交的时候,点会重合的问题,还在解决中。 d3路漫漫其修远兮呀