vue + d3.js 实现 拓扑图功能 (2)

154 阅读2分钟

111.png

前面一期,我们已经实现了利用d3.js渲染拓扑图,这期我们给节点增加拖拽功能,让功能更完善。

实现思路

  1. 每个节点被封装为 g 分组并绑定 d3.drag,支持按住节点拖动移动
  2. 拖拽过程中即时计算节点中心点,更新所有相关连线坐标,支持直线与贝塞尔曲线两种形式。
  3. 仅将渲染方式变为 group + transform,并新增 updateLinks 方法。

预期效果

可直接拖拽任意节点图标,关联连线会随之移动,动画小圆点仍沿更新后的路径流动。

代码片段

    drawView() {
      const width = this.$refs.topoChart.clientWidth;
      const height = this.$refs.topoChart.clientHeight;
      const svg = d3.select(this.$refs.topoChart)
                  .html('')
                  .append('svg')
                  .attr('width', width)
                  .attr('height', height);

      // 绘制线条
      this.addLines(svg);
      // 绘制节点
      this.addNodes(svg)

      this.addAnimation(svg);
    },
    
    // 绘制连线(贝塞尔曲线,支持自定义控制点)
    addLines(svg) {
      this.links.forEach(link => {
        const sourceNode = this.nodes.find(n => n.id === link.source);
        const targetNode = this.nodes.find(n => n.id === link.target);
        if (!sourceNode || !targetNode) return;

        const x1 = sourceNode.style.x + sourceNode.style.width / 2;
        const y1 = sourceNode.style.y + sourceNode.style.height / 2;
        const x2 = targetNode.style.x + targetNode.style.width / 2;
        const y2 = targetNode.style.y + targetNode.style.height / 2;

        if (link.points && link.points.length === 3) {
          const cp = link.points[1];
          svg.append('path')
            .attr('id', `link-path-${link.id}`)
            .attr('d', `M${x1},${y1} Q${cp.x},${cp.y} ${x2},${y2}`)
            .attr('stroke', '#00eaff')
            .attr('stroke-width', 4)
            .attr('fill', 'none')
            .attr('opacity', 0.7);
        } else {
          svg.append('line')
            .attr('id', `link-path-${link.id}`)
            .attr('x1', x1)
            .attr('y1', y1)
            .attr('x2', x2)
            .attr('y2', y2)
            .attr('stroke', '#00eaff')
            .attr('stroke-width', 4)
            .attr('opacity', 0.7);
        }
      });
    },

    // 绘制节点
    addNodes(svg) {
      const vm = this;
      this.nodes.forEach(node => {
        const group = svg.append('g')
          .attr('class', 'node-group')
          .attr('id', `node-${node.id}`)
          .attr('transform', `translate(${node.style.x}, ${node.style.y})`)
          .style('cursor', 'move')
          .attr('opacity', node.status === 0 ? 0.7 : 1)
          .call(
            d3.drag()
              .on('start', function (event) {
                const [px, py] = d3.pointer(event, svg.node());
                node._dragOffsetX = px - node.style.x;
                node._dragOffsetY = py - node.style.y;
                d3.select(this).raise();
              })
              .on('drag', function (event) {
                const [px, py] = d3.pointer(event, svg.node());
                node.style.x = px - (node._dragOffsetX || 0);
                node.style.y = py - (node._dragOffsetY || 0);
                d3.select(this).attr('transform', `translate(${node.style.x}, ${node.style.y})`);
                vm.updateLinks(svg);
              })
              .on('end', function () {
                delete node._dragOffsetX;
                delete node._dragOffsetY;
              })
          );

        // 节点图片(相对分组原点绘制)
        group.append('image')
          .attr('xlink:href', node.image)
          .attr('x', 0)
          .attr('y', 0)
          .attr('width', node.style.width)
          .attr('height', node.style.height);

        // 节点名称
        group.append('text')
          .attr('x', node.style.width + 10)
          .attr('y', 20)
          .attr('fill', '#fff')
          .attr('font-size', 18)
          .attr('font-weight', 'bold')
          .text(node.name);

        // attribute 文案
        node.attribute.forEach((attr, idx) => {
          group.append('text')
            .attr('x', node.style.width + 10)
            .attr('y', 40 + idx * 20)
            .attr('fill', '#fff')
            .attr('font-size', 14)
            .text(`${attr.name}: ${attr.value}`);
        });
      });
    },

    // 更新连线位置(在拖拽中实时调用)
    updateLinks(svg) {
      this.links.forEach(link => {
        const sourceNode = this.nodes.find(n => n.id === link.source);
        const targetNode = this.nodes.find(n => n.id === link.target);
        if (!sourceNode || !targetNode) return;

        const x1 = sourceNode.style.x + sourceNode.style.width / 2;
        const y1 = sourceNode.style.y + sourceNode.style.height / 2;
        const x2 = targetNode.style.x + targetNode.style.width / 2;
        const y2 = targetNode.style.y + targetNode.style.height / 2;

        const sel = svg.select(`#link-path-${link.id}`);
        if (sel.empty()) return;

        if (link.points && link.points.length === 3) {
          const cp = link.points[1];
          sel.attr('d', `M${x1},${y1} Q${cp.x},${cp.y} ${x2},${y2}`);
        } else {
          sel
            .attr('x1', x1)
            .attr('y1', y1)
            .attr('x2', x2)
            .attr('y2', y2);
        }
      });
    },

    // 流动动画
    addAnimation(svg) {
      // 遍历所有 path 和 link,确保每条 status 为 1 或 -1 的 link 都有动画圆点
      this.links.forEach(link => {
        if (link.status === 1 || link.status === -1) {
          // 生成 path 的唯一标识(用 id)
          const pathSelector = `#link-path-${link.id}`;
          const path = svg.select(pathSelector);

          if (!path.empty()) {
            // 在 path 上添加小圆点
            const circle = svg.append('circle')
              .attr('r', 7)
              .attr('fill', '#00eaff')
              .attr('stroke', '#fff')
              .attr('stroke-width', 1);

            // 动画函数
            const animateDot = () => {
              let t = link.status === 1 ? 0 : 1;
              const step = () => {
                const pathNode = path.node();
                const totalLength = pathNode.getTotalLength();
                const pos = pathNode.getPointAtLength(t * totalLength);
                circle.attr('cx', pos.x).attr('cy', pos.y);
                if (link.status === 1) {
                  t += 0.005;
                  if (t > 1) t = 0;
                } else {
                  t -= 0.005;
                  if (t < 0) t = 1;
                }
                requestAnimationFrame(step);
              };
              step();
            };
            animateDot();
          }
        }
      });
    }

上一期:

vue + d3.js 实现 拓扑图功能