拓扑图是一种抽象的网络结构图,用于展示节点(设备、系统等)和连接(关系、链路等)之间的关系。常见应用场景包括:
npm init vue@latest
cd my-vue-app
npm install
npm install d3 --save
interface Node {
id: string; // 节点唯一标识
name: string; // 节点名称
type?: string; // 节点类型
x?: number; // x坐标
y?: number; // y坐标
size?: number; // 节点大小
color?: string; // 节点颜色
[key: string]: any; // 其他自定义属性
}
interface Link {
source: string | Node; // 源节点
target: string | Node; // 目标节点
value?: number; // 连接值
type?: string; // 连接类型
[key: string]: any; // 其他自定义属性
}
interface TopologyData {
nodes: Node[];
links: Link[];
}
import * as d3 from 'd3';
const createForceSimulation = (width, height, nodes, links) => {
// 创建力模拟
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => d.size + 5));
return simulation;
};
const drag = (simulation) => {
const dragstarted = (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
};
const dragged = (event, d) => {
d.fx = event.x;
d.fy = event.y;
};
const dragended = (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
};
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
};
<!-- Topology.vue -->
<template>
<div class="topology-container" ref="container"></div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import * as d3 from 'd3';
const props = defineProps({
nodes: {
type: Array,
required: true
},
links: {
type: Array,
required: true
},
width: {
type: Number,
default: 800
},
height: {
type: Number,
default: 600
}
});
const container = ref(null);
let svg, simulation, linkElements, nodeElements;
const createTopology = () => {
// 清除现有内容
if (svg) svg.remove();
// 创建SVG容器
svg = d3.select(container.value)
.append('svg')
.attr('width', props.width)
.attr('height', props.height)
.attr('viewBox', `0 0 ${props.width} ${props.height}`)
.attr('style', 'max-width: 100%; height: auto;');
// 添加背景
svg.append('rect')
.attr('width', '100%')
.attr('height', '100%')
.attr('fill', '#f9fafb');
// 创建链接元素
linkElements = svg.append('g')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6)
.selectAll('line')
.data(props.links)
.join('line')
.attr('stroke-width', d => d.value || 1);
// 创建节点元素
nodeElements = svg.append('g')
.selectAll('circle')
.data(props.nodes)
.join('circle')
.attr('r', d => d.size || 10)
.attr('fill', d => d.color || '#5b8ff9')
.call(drag(simulation));
// 添加节点标签
const labels = svg.append('g')
.attr('font-family', 'sans-serif')
.attr('font-size', 12)
.selectAll('text')
.data(props.nodes)
.join('text')
.attr('dy', '.35em')
.attr('text-anchor', 'middle')
.text(d => d.name);
// 定义力模拟
simulation = d3.forceSimulation(props.nodes)
.force('link', d3.forceLink(props.links).id(d => d.id))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(props.width / 2, props.height / 2))
.force('collision', d3.forceCollide().radius(d => (d.size || 10) + 5));
// 更新模拟
simulation.on('tick', () => {
linkElements
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
nodeElements
.attr('cx', d => d.x)
.attr('cy', d => d.y);
labels
.attr('x', d => d.x)
.attr('y', d => d.y);
});
};
// 拖拽功能
const drag = (simulation) => {
const dragstarted = (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
};
const dragged = (event, d) => {
d.fx = event.x;
d.fy = event.y;
};
const dragended = (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
};
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
};
onMounted(() => {
createTopology();
});
watch([() => props.nodes, () => props.links], () => {
if (simulation) {
// 更新模拟数据
simulation.nodes(props.nodes);
simulation.force('link').links(props.links);
simulation.alpha(1).restart();
}
});
onBeforeUnmount(() => {
if (simulation) {
simulation.stop();
}
});
</script>
<style scoped>
.topology-container {
width: 100%;
height: 100%;
min-height: 400px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background-color: #f9fafb;
}
</style>
// 在nodeElements创建后添加点击事件
nodeElements
.attr('r', d => d.size || 10)
.attr('fill', d => d.color || '#5b8ff9')
.call(drag(simulation))
.on('click', (event, d) => {
// 触发Vue事件
emit('nodeClick', d);
})
.on('mouseover', (event, d) => {
// 高亮节点
d3.select(event.currentTarget)
.attr('fill', '#ff7d00')
.attr('r', (d.size || 10) + 2);
})
.on('mouseout', (event, d) => {
// 恢复节点样式
d3.select(event.currentTarget)
.attr('fill', d.color || '#5b8ff9')
.attr('r', d.size || 10);
});
<template>
<div class="container">
<h3 class="text-xl font-bold mb-4">网络拓扑图示例</h3>
<Topology
:nodes="nodes"
:links="links"
:width="800"
:height="600"
@nodeClick="handleNodeClick"
/>
</div>
</template>
<script setup>
import Topology from './components/Topology.vue';
import { ref } from 'vue';
const nodes = ref([
{ id: 'router', name: '核心路由器', type: 'router', size: 15, color: '#5b8ff9' },
{ id: 'switch1', name: '交换机1', type: 'switch', size: 12, color: '#69b1ff' },
{ id: 'switch2', name: '交换机2', type: 'switch', size: 12, color: '#69b1ff' },
{ id: 'server1', name: '应用服务器', type: 'server', size: 12, color: '#7dc366' },
{ id: 'server2', name: '数据库服务器', type: 'server', size: 12, color: '#7dc366' },
{ id: 'client1', name: '客户端1', type: 'client', size: 10, color: '#ff7d00' },
{ id: 'client2', name: '客户端2', type: 'client', size: 10, color: '#ff7d00' },
{ id: 'client3', name: '客户端3', type: 'client', size: 10, color: '#ff7d00' }
]);
const links = ref([
{ source: 'router', target: 'switch1', value: 2 },
{ source: 'router', target: 'switch2', value: 2 },
{ source: 'switch1', target: 'server1', value: 1 },
{ source: 'switch1', target: 'server2', value: 1 },
{ source: 'switch2', target: 'client1', value: 1 },
{ source: 'switch2', target: 'client2', value: 1 },
{ source: 'switch2', target: 'client3', value: 1 }
]);
const handleNodeClick = (node) => {
console.log('点击了节点:', node);
alert(`点击了节点: ${node.name}`);
};
</script>
<template>
<div class="container">
<h3 class="text-xl font-bold mb-4">实时更新拓扑图</h3>
<div class="flex mb-4">
<button @click="addNode" class="px-4 py-2 bg-blue-500 text-white rounded mr-2">添加节点</button>
<button @click="removeNode" class="px-4 py-2 bg-red-500 text-white rounded mr-2">删除节点</button>
<button @click="randomizePositions" class="px-4 py-2 bg-green-500 text-white rounded">随机位置</button>
</div>
<Topology
:nodes="nodes"
:links="links"
:width="800"
:height="600"
/>
</div>
</template>
<script setup>
import Topology from './components/Topology.vue';
import { ref } from 'vue';
const nodes = ref([
{ id: 'node1', name: '节点1', size: 12 },
{ id: 'node2', name: '节点2', size: 12 },
{ id: 'node3', name: '节点3', size: 12 }
]);
const links = ref([
{ source: 'node1', target: 'node2' },
{ source: 'node2', target: 'node3' }
]);
let nodeId = 4;
const addNode = () => {
const newNode = {
id: `node${nodeId++}`,
name: `节点${nodeId - 1}`,
size: 12,
x: Math.random() * 800,
y: Math.random() * 600
};
nodes.value.push(newNode);
// 随机连接到现有节点
if (nodes.value.length > 1) {
const randomNode = nodes.value[Math.floor(Math.random() * (nodes.value.length - 1))];
links.value.push({
source: newNode.id,
target: randomNode.id
});
}
};
const removeNode = () => {
if (nodes.value.length > 1) {
const lastNode = nodes.value.pop();
// 移除相关连接
links.value = links.value.filter(link =>
link.source !== lastNode.id && link.target !== lastNode.id
);
}
};
const randomizePositions = () => {
nodes.value.forEach(node => {
node.x = Math.random() * 800;
node.y = Math.random() * 600;
});
};
</script>
<template>
<div class="container">
<h3 class="text-xl font-bold mb-4">复杂拓扑图示例</h3>
<div class="flex mb-4">
<div class="mr-4">
<label class="block text-sm font-medium text-gray-700 mb-1">布局类型</label>
<select v-model="layoutType" @change="updateLayout">
<option value="force">力导向布局</option>
<option value="circle">环形布局</option>
<option value="grid">网格布局</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">节点大小</label>
<input type="range" min="5" max="20" v-model.number="nodeSize" @input="updateNodeSize">
</div>
</div>
<Topology
:nodes="nodes"
:links="links"
:width="800"
:height="600"
/>
</div>
</template>
<script setup>
import Topology from './components/Topology.vue';
import { ref } from 'vue';
const nodes = ref([]);
const links = ref([]);
const layoutType = ref('force');
const nodeSize = ref(12);
// 生成随机数据
const generateData = (count = 30) => {
const newNodes = [];
const newLinks = [];
// 生成节点
for (let i = 0; i < count; i++) {
newNodes.push({
id: `node${i}`,
name: `节点${i}`,
type: i % 3 === 0 ? 'server' : i % 3 === 1 ? 'switch' : 'client',
size: nodeSize.value,
color: i % 3 === 0 ? '#5b8ff9' : i % 3 === 1 ? '#7dc366' : '#ff7d00'
});
}
// 生成连接
for (let i = 0; i < count; i++) {
const connections = Math.floor(Math.random() * 3) + 1;
for (let j = 0; j < connections; j++) {
const targetId = Math.floor(Math.random() * count);
if (i !== targetId && !newLinks.some(link =>
(link.source === `node${i}` && link.target === `node${targetId}`) ||
(link.source === `node${targetId}` && link.target === `node${i}`)
)) {
newLinks.push({
source: `node${i}`,
target: `node${targetId}`,
value: Math.random() * 3 + 1
});
}
}
}
nodes.value = newNodes;
links.value = newLinks;
};
const updateLayout = () => {
if (layoutType.value === 'circle') {
// 环形布局
const radius = 300;
const angleStep = (Math.PI * 2) / nodes.value.length;
nodes.value.forEach((node, index) => {
node.x = 400 + radius * Math.cos(angleStep * index);
node.y = 300 + radius * Math.sin(angleStep * index);
});
} else if (layoutType.value === 'grid') {
// 网格布局
const cols = Math.ceil(Math.sqrt(nodes.value.length));
const rows = Math.ceil(nodes.value.length / cols);
const cellWidth = 700 / (cols + 1);
const cellHeight = 500 / (rows + 1);
nodes.value.forEach((node, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
node.x = 50 + cellWidth * (col + 1);
node.y = 50 + cellHeight * (row + 1);
});
}
};
const updateNodeSize = () => {
nodes.value.forEach(node => {
node.size = nodeSize.value;
});
};
// 初始化数据
generateData();
</script>
// 在Topology组件中扩展节点类型
nodeElements = svg.append('g')
.selectAll('g')
.data(props.nodes)
.join('g')
.attr('class', 'node')
.call(drag(simulation));
// 根据节点类型渲染不同形状
nodeElements.each(function(d) {
const nodeGroup = d3.select(this);
if (d.type === 'router') {
nodeGroup.append('rect')
.attr('width', d.size * 2)
.attr('height', d.size * 2)
.attr('x', -d.size)
.attr('y', -d.size)
.attr('fill', d.color || '#5b8ff9')
.attr('rx', 4);
} else if (d.type === 'server') {
nodeGroup.append('rect')
.attr('width', d.size * 1.5)
.attr('height', d.size * 2)
.attr('x', -d.size * 0.75)
.attr('y', -d.size)
.attr('fill', d.color || '#7dc366');
} else {
nodeGroup.append('circle')
.attr('r', d.size)
.attr('fill', d.color || '#ff7d00');
}
// 添加图标或文本
nodeGroup.append('text')
.attr('dy', '.35em')
.attr('text-anchor', 'middle')
.attr('fill', 'white')
.text(d.name);
});
// 定制连接样式
linkElements = svg.append('g')
.selectAll('path')
.data(props.links)
.join('path')
.attr('fill', 'none')
.attr('stroke-width', d => d.value || 1)
.attr('stroke', d => {
if (d.type === 'critical') return '#ff4d4f';
if (d.type === 'warning') return '#faad14';
return '#999';
})
.attr('stroke-opacity', 0.6);
// 更新模拟时使用曲线连接
simulation.on('tick', () => {
linkElements.attr('d', d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
return `M ${d.source.x} ${d.source.y}
A ${dr} ${dr} 0 0,1 ${d.target.x} ${d.target.y}`;
});
// 节点和标签位置更新代码...
});
// 添加节点悬停效果
nodeElements.on('mouseover', (event, d) => {
d3.select(event.currentTarget)
.transition()
.duration(200)
.attr('transform', 'scale(1.2)')
.attr('z-index', 100);
// 显示详情提示框
tooltip.transition()
.duration(200)
.style('opacity', 0.9);
tooltip.html(`
<div class="tooltip-title">${d.name}</div>
<div class="tooltip-content">
<p>ID: ${d.id}</p>
<p>类型: ${d.type || '未知'}</p>
${d.capacity ? `<p>容量: ${d.capacity}</p>` : ''}
</div>
`)
.style('left', `${event.pageX}px`)
.style('top', `${event.pageY - 28}px`);
})
.on('mouseout', (event, d) => {
d3.select(event.currentTarget)
.transition()
.duration(200)
.attr('transform', 'scale(1)')
.attr('z-index', 1);
// 隐藏提示框
tooltip.transition()
.duration(500)
.style('opacity', 0);
});
// 添加节点点击动画
nodeElements.on('click', (event, d) => {
d3.select(event.currentTarget)
.transition()
.duration(300)
.attr('fill', '#ff4d4f')
.transition()
.duration(300)
.attr('fill', d.color || '#5b8ff9');
});
// 使用WebWorker处理大量数据计算
// worker.js
self.onmessage = function(e) {
const { nodes, links, width, height } = e.data;
// 初始化d3力模拟
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2));
// 运行模拟并返回结果
simulation.on('tick', () => {
self.postMessage({
nodes: nodes.map(node => ({ id: node.id, x: node.x, y: node.y })),
progress: simulation.alpha()
});
});
};
// 在Vue组件中使用
const worker = new Worker('worker.js');
worker.onmessage = (e) => {
if (e.data.progress < 0.01) {
// 模拟完成
updateNodes(e.data.nodes);
worker.terminate();
}
};
worker.postMessage({
nodes: props.nodes,
links: props.links,
width: props.width,
height: props.height
});
// 使用分层渲染提高性能
const defs = svg.append('defs');
// 创建渐变
defs.append('linearGradient')
.attr('id', 'linkGradient')
.attr('x1', '0%')
.attr('y1', '0%')
.attr('x2', '100%')
.attr('y2', '0%')
.selectAll('stop')
.data([
{ offset: '0%', color: '#5b8ff9' },
{ offset: '100%', color: '#7dc366' }
])
.enter()
.append('stop')
.attr('offset', d => d.offset)
.attr('stop-color', d => d.color);
// 使用渐变绘制连接
linkElements = svg.append('g')
.selectAll('line')
.data(props.links)
.join('line')
.attr('stroke', 'url(#linkGradient)')
.attr('stroke-width', d => d.value || 1);
通过结合Vue和D3,我们可以实现功能强大、交互丰富的可拖拽拓扑图:
这种组合方式充分发挥了Vue和D3各自的优势,既保证了开发效率,又提供了出色的用户体验。您可以根据实际需求进一步扩展功能,如添加节点编辑、导出功能、搜索过滤等。
Vue,D3, 可拖拽拓扑图,前端开发,数据可视化,JavaScript,HTML,CSS, 响应式设计,交互设计,Web 应用,技术方案,应用实例,动态渲染,用户体验
资源地址:
https://pan.quark.cn/s/0f46128d9374
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。