先看效果图,可拖拽,可实现页面的缩放和平移
(1)用jsplumb实现拓扑图的绘制以及拖拽功能
(2)用panzoom实现缩放和平移功能
引入jsplumb、panzoom
npm install jsplumb --save
npm install panzoom --save
直接上代码,以后有时间在整理
copy 到工程里可以直接使用
目录结构
index.vue
<template>
<div class="flow-view" ref="flowView">
<!-- 设置拓扑图区域 -->
<div id="jsplumb-flow" ref="JsplumbFlow">
<div class="manage">
<CardView :list="list1" id="manage-area" />
</div>
<div class="system">
// 点击节点高亮,目前只加了中间这个
<CardGroupView :list="list2" id="system-area" @clickNode="handleClickNode" />
</div>
<div class="serve">
<CardGroupView :list="list3" id="serve-area" />
</div>
</div>
</div>
</template>
<script>
import jsplumbModule from 'jsplumb'
import CardView from './components/cardView.vue'
import CardGroupView from './components/cardGroupView.vue'
import { jsPlumbDefaultConfig } from './jsplumbConfig'
import panzoom from "panzoom";
import { debounce } from '@/utils/utils'
const jsplumb = jsplumbModule.jsPlumb
export default {
components: { CardView, CardGroupView },
data() {
return {
list1: [],
list2: [],
list3: [],
connectList: [],
jsplumbInstance: null,
panzoomInstance: null,
remberSelectNodeId: '', // 记录当前选中的节点,重复点击,回复默认颜色
resizeObserver : null
}
},
mounted() {
this.resizeObserver = new ResizeObserver(debounce(() => {
this.handleResize()
}), 500)
this.resizeObserver.observe(this.$refs.flowView)
// 加载数据
this.getData().then(() => {
this.$nextTick(() => {
// 初始化jsplumb
this.initJsplumb()
})
})
},
methods: {
initJsplumb() {
jsplumb.ready(() => {
//设置jsplumb实例、设置jsplumb默认配置、设置jsplumb容器
this.jsplumbInstance = jsplumb.getInstance().importDefaults(jsPlumbDefaultConfig)
// 重设container
// this.jsplumbInstance.setContainer('jsplumb-flow')
// 先清除一下画布,防止缓存
this.jsplumbInstance.reset();
// 处理节点数据
this.handleNodeData()
// // 会使整个jsPlumb立即重绘。
// this.jsplumbInstance.setSuspendDrawing(false, true);
this.initPanZoom()
})
},
// 处理数据,绘制节点
handleNodeData() {
this.list1.forEach((item) => {
// 设置拖拽, 拖拽方法,调用接口重绘的时候,settingDrag 不需要重复调用,不然会报错
// 我猜测可能是节点已经存在,目前我的解决方法是传一个参数,控制是否调用settingDrag方法
this.settingDrag(item)
// 初始化节点
this.initNodes(item)
})
this.list2.forEach((item) => {
this.settingDrag(item)
this.initNodes(item)
})
this.list3.forEach((item) => {
this.settingDrag(item)
this.initNodes(item)
})
// 节点连接的数据
this.connectList.forEach(item => {
const { source, target } = item
this.nodeConnect(source.nodeId, target.nodeId)
})
},
// 初始化节点
initNodes(node) {
// let endPointConfig = {}
const { nodeId } = node
if (node.type === 1) {
// 这是左侧第一列,只需要右侧连接点
// uuid 设置nodeID-Right,为了节点相连,对准方向
this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Right', uuid: `${nodeId}-Right` }, jsPlumbDefaultConfig)
} else if (node.type === 2) {
// 中间数据
// 递归调用,将子节点下的所有节点,全部加入endPoint
if (node.children && node.children.length > 0) {
node.children.forEach(item => {
this.initNodes({...item, ...{ type: 2 }})
})
}
// 添加父节点,如果不需要链接父节点,可不写
this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Right', uuid: `${nodeId}-Right` }, jsPlumbDefaultConfig)
this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Left', uuid: `${nodeId}-Left` }, jsPlumbDefaultConfig)
} else {
// 右侧数据,只需要暴露左侧连接点
if (node.children && node.children.length > 0) {
node.children.forEach(item => {
this.initNodes({...item, ...{ type: 3 }})
})
}
this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Left', uuid: `${nodeId}-Left` }, jsPlumbDefaultConfig)
}
},
// 节点相连
nodeConnect(sourceId, targetId) {
this.jsplumbInstance.connect({ uuids: [`${sourceId}-Right`, `${targetId}-Left`]})
},
settingDrag(node) {
// 也可以限制节点的拖拽区域
// if (node.type === 1) {
// this.jsplumbInstance.draggable(node.nodeId, {
// containment: 'manage-area'
// });
// } else if (node.type === 2) {
// this.jsplumbInstance.draggable(node.nodeId, {
// containment: 'system-area'
// });
// } else {
// this.jsplumbInstance.draggable(node.nodeId, {
// containment: 'serve-area'
// });
// 设置节点可拖拽
this.jsplumbInstance.draggable(node.nodeId);
},
getData() {
return new Promise((resolve, reject) => {
// 添加节点数据
const arr1 = []
const arr2 = []
const arr3 = []
for (let i = 1; i < 20; i++) {
if (i <= 6) {
arr1.push({
name: '管理' + i,
nodeId: 'manage' + i,
type: 1
})
} else if (i < 13) {
const childrenList = [
{
name: '系统' + i + '-ip1',
nodeId: 'system' + i + 'ip1',
},
{
name: '系统' + i + '-ip2',
nodeId: 'system' + i + 'ip2',
},
{
name: '系统' + i + '-ip3',
nodeId: 'system' + i + 'ip3',
}
]
arr2.push({
name: '系统' + i,
nodeId: 'system' + i,
type: 2,
children: i === 7 ? childrenList : []
})
} else {
const childrenList = [
{
name: '服务' + i + '-ip1',
nodeId: 'serve' + i + 'ip1',
},
{
name: '服务' + i + '-ip2',
nodeId: 'serve' + i + 'ip2',
},
{
name: '服务' + i + '-ip3',
nodeId: 'serve' + i + 'ip3',
}
]
arr3.push({
name: '服务' + i,
nodeId: 'serve' + i,
type: 3,
children: i === 14 ? childrenList : []
})
}
}
// 设置节点位置,如果需要拖拽,node节点必须使用absolute,绝对定位去设置坐标点
this.fixNodesPosition(arr1, 1)
this.fixNodesPosition(arr2, 2)
this.fixNodesPosition(arr3, 3)
// 获取节点相连的数据
this.connectList = [
{ source: arr1[1], target: arr2[0].children[0] },
{ source: arr2[0].children[2], target: arr3[1].children[1] },
]
resolve()
})
},
// 手动计算节点位置
fixNodesPosition(arr, type) {
const topSpace = 12
if (type === 1) {
const modeWidth = 150
const modeHeight = 35
const width = this.$refs.flowView.offsetWidth
const modeLeft = (width / 3 / 2) - (modeWidth / 2)
arr.forEach((item, i) => {
item['width'] = modeWidth
item['height'] = modeHeight
item['top'] = (modeHeight * i) + (topSpace * (i + 1))
item['left'] = modeLeft
})
this.list1 = arr
} else if (type === 2 || type === 3) {
const modeWidth = 150
const emptyHeight = 40
const headerHeight = 30
const rowHeight = 30
const width = this.$refs.flowView.offsetWidth
const modeLeft = (width / 3 / 2) - (modeWidth / 2)
let totalHeight = 0
arr.forEach((item, i) => {
let modeHeight = 0
if (item.children && item.children.length > 0) {
if (item.children.length * rowHeight < 30) {
modeHeight = headerHeight + emptyHeight
} else {
modeHeight = (item.children.length * rowHeight) + headerHeight
}
} else {
modeHeight = headerHeight + emptyHeight
}
item['width'] = modeWidth
item['height'] = modeHeight
item['top'] = totalHeight + (topSpace * (i + 1))
item['left'] = modeLeft
totalHeight = modeHeight + totalHeight
})
if (type === 2) {
this.list2 = arr
} else {
this.list3 = arr
}
}
},
// 使用panZoom 实现缩放功能
//初始化缩放功能
initPanZoom() {
// panzoom(缩放区域,相关配置)
this.panzoomInstance = panzoom(this.$refs.JsplumbFlow, {
smoothScroll: false,
bounds: true,
// autocenter: true,
zoomDoubleClickSpeed: 1,
minZoom: 0.5,
maxZoom: 2,
})
},
handleClickNode(node) {
// 重置所有线条颜色
const connectionsAll = this.jsplumbInstance.getAllConnections()
connectionsAll.map(item => {
item.setPaintStyle({ stroke: '#000' })
})
// 设置source链接线
const connect = this.jsplumbInstance.getConnections({
source: node.nodeId,
target: ''
})
// target
const connect2 = this.jsplumbInstance.getConnections({
source: '',
target: node.nodeId
})
connect.map((item) => {
item.setPaintStyle({ stroke: 'red' })
})
connect2.map((item) => {
item.setPaintStyle({ stroke: 'red' })
})
},
handleResize() {
// 改变view尺寸后,重新连线
this.jsplumbInstance.repaintEverything()
},
beforeDestroy() {
// 组件销毁前移除panzoom以避免内存泄露
this.panzoomInstance.dispose();
this.resizeObserver.unobserve(this.$refs.flowView)
this.resizeObserver.disconnect()
}
}
}
</script>
<style lang="less" scoped>
.flow-view {
width: 100%;
height: 500px;
border: 1px solid gray;
overflow: hidden;
#jsplumb-flow {
position: relative;
width: 100%;
height: 100%;
display: flex;
.manage, .system, .serve {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
p {
text-align: center;
font-size: 20px;
font-weight: bold;
margin: 0;
height: 40px;
line-height: 40px;
}
}
// .manage {
// background-color: honeydew;
// }
// .system {
// background-color: pink;
// }
// .serve {
// background-color: paleturquoise;
// }
}
}
</style>
jsplumbConfig/index.js
// 基础配置
export const jsPlumbDefaultConfig = {
Container: "jsplumb-flow",
Anchors: ['Left', 'Right'],
//四种样式:Bezier/Straight/Flowchart/StateMachine
Connector: ["Bezier"],
// Connector: ["Straight", {stub: [20, 50], gap: 0}],
// Connector: ["Flowchart", { stub: [20, 10], gap: 10, cornerRadius: 5, alwaysRespectStubs: true }],
// Connector: ["StateMachine"],
// 连线的端点
Endpoint: "Blank", // Blank:空,不可见;Dot:圆点;Image;Rectangle
// 端点的样式
EndpointStyle: {
fill: "#c4c4c4",
outlineWidth: 10
},
// 通常连线的样式
PaintStyle: {
stroke: '#000',
strokeWidth: 2,
outlineWidth: 20
},
//hover激活连线的样式
HoverPaintStyle: {
stroke: 'blue',
strokeWidth: 2
},
maxConnections: -1, // 设置连接点最多可以连接几条线 -1不限
// 绘制箭头
Overlays: [
[
"Arrow",
{
width: 8, // 箭头宽度
length: 8, // 箭头长度
location: 1 // 线尾部(0-1)
}
]
],
DrapOptions: { cursor: "crosshair", zIndex: 2000 },
}
components/cardView.vue
<template>
<div class="card-view">
<div
v-for="item in list"
:key="item.nodeId"
:id="item.nodeId"
:style="getStyle(item)"
class="item"
>
{{ item.name }}
</div>
</div>
</template>
<script>
export default {
props: {
list: {
type: Array,
default: () => []
}
},
data() {
return {
}
},
methods: {
// 根据计算的top、left、width、height,设置style
getStyle(item) {
return {
width: `${item.width}px`,
height: `${item.height}px`,
top: `${item.top}px`,
left: `${item.left}px`,
lineHeight: `${item.height}px`
}
}
}
}
</script>
<style lang="less" scoped>
.card-view {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
// top: 40px;
top: 0;
left: 0;
right: 0;
bottom: 0;
.item {
text-align: center;
background: orange;
position: absolute;
font-size: 14px;
}
}
</style>
components/cardGroupView.vue
<template>
<div class="card-view">
<div
v-for="item in list"
:key="item.nodeId"
:id="item.nodeId"
:style="getStyle(item)"
class="item"
>
<p class="title">{{ item.name }}</p>
<div class="children">
<template v-if="item.children && item.children.length > 0">
<p
v-for="childrenItem in item.children"
:key="childrenItem.nodeId"
:id="childrenItem.nodeId"
class="row"
@click="clickNode(item.children[childIndex])"
>
{{ childrenItem.name }}
</p>
</template>
<p v-else class="empty">
{{ emptyText }}
</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
emptyText: {
type: String,
default: '暂无设备'
},
list: {
type: Array,
default: () => []
}
},
data() {
return {
}
},
methods: {
// 根据计算的top、left、width、height,设置style
getStyle(item) {
return {
width: `${item.width}px`,
height: `${item.height}px`,
top: `${item.top}px`,
left: `${item.left}px`,
lineHeight: `${item.height}px`
}
},
clickNode(node) {
this.$emit('clickNode', node)
}
}
}
</script>
<style lang="less" scoped>
.card-view {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
// top: 40px;
top: 0;
left: 0;
right: 0;
bottom: 0;
.item {
text-align: center;
background: orange;
position: absolute;
font-size: 14px;
border: 1px solid gray;
box-sizing: border-box;
display: flex;
flex-direction: column;
.title {
border-bottom: 1px solid gray;
box-sizing: border-box;
}
p {
margin: 0;
height: 30px;
line-height: 30px;
}
.children {
display: flex;
flex-direction: column;
min-height: 40px;
justify-content: space-around;
flex: 1;
p {
height: 20px;
line-height: 20px;
}
.row {
height: 20px;
line-height: 20px;
margin: 0 16px;
font-size: 12px;
border: 1px solid gray;
}
}
}
}
</style>
新增:点击节点,高亮显示连接线,同步更新在上面代碼裏
主要代码:
method: {
handleClickNode(node) {
// 增加判断,重复点击恢复默认颜色
if (node.nodeId === this.remberSelectNodeId) {
this.remberSelectNodeId = ''
this.settingDefaultLine()
return
}
this.remberSelectNodeId = node.nodeId
this.settingDefaultLine()
this.settingSelectLine({ source: node.nodeId, target: '' })
this.settingSelectLine({ source: '', target: node.nodeId })
},
// 设置高亮颜色
settingSelectLine(lineObj) {
const connect = this.jsplumbInstance.getConnections(lineObj)
connect.map((item) => {
item.setPaintStyle({ stroke: 'red' })
})
},
settingDefaultLine() {
// 重置所有线条颜色
const connectionsAll = this.jsplumbInstance.getAllConnections()
connectionsAll.map(item => {
item.setPaintStyle({ stroke: '#000' })
})
},
}