需求背景
最近遇到一个拓扑图的需求,不同的站点拓扑图内容展示不同。于是,就考虑采用数据驱动的方式来生成拓扑图+动效(电流流动、图片高亮等)。调研了一些实现方式,发现d3.js非常适合。
效果如图:
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%;
}