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

1,197 阅读2分钟

需求背景

最近遇到一个拓扑图的需求,不同的站点拓扑图内容展示不同。于是,就考虑采用数据驱动的方式来生成拓扑图+动效(电流流动、图片高亮等)。调研了一些实现方式,发现d3.js非常适合。

效果如图:

111.png

d3.js的核心概念

  • 数据驱动:d3.js的核心思想是使用数据驱动的方式来构建可视化,这意味着数据本身是可操作的,可以根据需要更改和修改。
  • DOM操作:d3.js使用DOM操作来更新和创建HTML元素,这使得D3.js非常高效,可以处理大量数据并在实时更新数据时保持流畅的性能。
  • 数据绑定:d3.js使用数据绑定来将数据与DOM元素关联起来,这使得D3.js非常灵活,可以创建各种各样的可视化图表。

技术栈:

vue2 + d3.js

安装 d3.js

npm i d3@7.9.0 --save

核心代码

DOM代码

    <div style="width: 100%; height: 700px">
        <div ref="topoChart" class="svg"></div>
    </div>

JavaScript 代码

import * as d3 from 'd3'

export default {
  data() {
    return {
      nodes: [
        {
          id: 'node1',
          name: '光伏',
          image: require('@/assets/images/home/card_1@2x.png'),
          attribute: [
            {
              name: "PV",
              value: "12.2 kW"
            },
            {
              name: "Ua",
              value: "9.2 V"
            },
            {
              name: "Ub",
              value: "5.01 V"
            },
            {
              name: "Uc",
              value: "19.2 V"
            }
          ],
          style: {
            width: 80,
            height: 80,
            x: 120,
            y: 120
          },
          status: 1 // 0: 灰色 1: 亮色
        },
        {
          id: 'node2', 
          name: '负载', 
          image: require('@/assets/images/home/card_2@2x.png'),
          attribute: [
            {
              name: "AP",
              value: "12.2 kW"
            },
            {
              name: "Rp",
              value: "9.2 kVar"
            },
            {
              name: "Df",
              value: "-"
            }
          ],
          style: {
            width: 80,
            height: 80,
            x: 520,
            y: 220
          },
          status: 0 // 0: 灰色 1: 亮色
        },
        {
          id: 'node3', 
          name: '电网', 
          image: require('@/assets/images/home/card_3@2x.png'),
          attribute: [
            {
              name: "PV",
              value: "821.2 kW"
            }
          ],
          style: {
            width: 80,
            height: 80,
            x: 320,
            y: 320
          },
          status: 1 // 0: 灰色 1: 亮色 
        },
        {
          id: 'node4', 
          name: '发电机', 
          image: require('@/assets/images/home/card_4@2x.png'),
          attribute: [
            {
              name: "AP",
              value: "12.2 kW"
            },
            {
              name: "Rp",
              value: "9.2 kVar"
            },
          ],
          style: {
            width: 80,
            height: 80,
            x: 720,
            y: 520
          },
          status: 1 // 0: 灰色 1: 亮色
        },
      ],
      links: [
        {
          id: 'link1',
          source: 'node1',
          target: 'node2',
          status: 1,
          points: [
            // 起点、控制点、终点
            { x: 160, y: 160 }, // node1中心
            { x: 300, y: 70 }, // 控制点
            { x: 560, y: 260 }  // node2中心
          ]
        },
        { id: 'link2', source: 'node2', target: 'node3', status: 0 },
        { id: 'link3', source: 'node4', target: 'node3', status: 1 },
      ]
    }
  },
  mounted() {
    this.drawView()
  },
  methods: {
    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);
    },

    // 绘制线条
    addNodes(svg) {
      this.nodes.forEach(node => {
        // 节点图片
        svg.append('image')
          .attr('xlink:href', node.image)
          .attr('x', node.style.x)
          .attr('y', node.style.y)
          .attr('width', node.style.width)
          .attr('height', node.style.height)
          .attr('opacity', node.status === 0 ? 0.7 : 1)

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

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

     // 绘制连线(贝塞尔曲线,支持自定义控制点)
    addLines(svg) {
      this.links.forEach(link => {
        if (link.points && link.points.length === 3) {
          svg.append('path')
            .attr('id', `link-path-${link.id}`)
            .attr('d', `M${link.points[0].x},${link.points[0].y} Q${link.points[1].x},${link.points[1].y} ${link.points[2].x},${link.points[2].y}`)
            .attr('stroke', '#00eaff')
            .attr('stroke-width', 4)
            .attr('fill', 'none')
            .attr('opacity', 0.7)
        } else {
          const sourceNode = this.nodes.find(n => n.id === link.source)
          const targetNode = this.nodes.find(n => n.id === link.target)
          if (sourceNode && targetNode) {
            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
            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)
          }
        }
      })
    },

    // 流动动画
    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();
          }
        }
      });
    }
  }
}

CSS代码

.svg {
  width: 100%;
  height: 100%;
}