前面一期,我们已经实现了利用d3.js渲染拓扑图,这期我们给节点增加拖拽功能,让功能更完善。
实现思路
- 每个节点被封装为 g 分组并绑定 d3.drag,支持按住节点拖动移动
- 拖拽过程中即时计算节点中心点,更新所有相关连线坐标,支持直线与贝塞尔曲线两种形式。
- 仅将渲染方式变为 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();
}
}
});
}