项目上有个需求要做一个图谱的功能, 设计图是这样:
要求节点可以拖拽,节点之间可以连线。
首先想到的就是用d3.js实现这个功能,然后就去看了d3的文档,和示例项目,确定可以实现所需要的效果,开始着手实现,下面是我的实现思路:
首先,项目使用的是vue3,思路是在获取到数据以后,将数据组装一下,组装成需要的结构,然后在组件onMounted的时候去初始化插件。
在方法内部使用watch实现数据变化监听,这是因为需求涉及到新增、修改和删除的功能,所以只要在组件内修改数据就能达到更新图谱的效果。
在数据初始化的时候会传递一个init字段,主要是为了区分数据是初始化还是修改数据。
需要注意一点,使用watch的这种方式实现功能,不能在原数据上绑定力模型(d3.forceSimulation),因为在
力仿真即tick的时候,会在nodes数据节点上增加index、x、y、vx、vy坐标数据,并且这些数据会不断变化,如果监听的是传入的原数据,那么这些坐标属性就会绑定在原数据上,导致循环触发watch,会报错,所以这里我的做法是缓存一份数据,用缓存的数据去初始化力模型,这样就实现了原数据的隔离,watch监听的是原数据,而力模型绑定的是缓存的数据。在组件内修改数据时触发watch,再根据init区分修改数据,更新数据的思路是遍历传入的节点数据在缓存的数据中心根据id查找相同节点,找到了则说明是修改节点数据,用Object.assign将新数据更新到缓存的数据上;如果没找到则说明是新增节点,则Object.assign创建一个新的节点push到缓存的数据上,这里注意,在push节点的时候,需要重启一下力模型,simulation.nodes(cacheData.nodes).restart(),再插入节点,即实现了数据更新。关系连线是同样的道理。
使用d3.js实现图谱的思路就是这样,还有一些细节,稍后再讲,说说遇到的问题:
- 最严重的一个问题就是性能问题,或者说是渲染问题,正常情况下在我们节点较少(100个以内)的时候,是没有问题的,但是如果数量比较大的时候就会出现卡顿的情况,甚至会把浏览器卡住,原因是d3本身是使用svg实现构图的,而且在节点越多,页面上的DOM元素就越多,渲染就会很卡顿,再加上绑定了力模型,在仿真的时候回调函数会不断执行,节点的定位属性x、y等会不断地修改,大量dom同时重新修改,使本就卡顿的页面更加雪上加霜。所以在查询了很多示例和网友提供的解决方案发现,使用d3+canvas的性能会比svg的好,所以决定重新用canvas写一份。
d3 + canvas
首先改造的背景是仅做展示使用,不涉及增删改节点,所以业务逻辑可能没有之前svg的复杂。
先看效果:
这是点较少的情况
这是点稍微较多的情况
看上去还是蛮丝滑的吧? 下面说一下我开发时的实现思路:
首先,先定义存储数据的变量
const nodes = ref<node[]>([])
const links = ref<link[]>([])
因为不涉及增删改的情况,所以不需要缓存数据,直接用请求回来的数据初始化力模型就行可以了。
function init() {
simulation = d3
.forceSimulation(nodes.value)
.force('charge', d3.forceManyBody().distanceMax(100))
.force('center', d3.forceCenter(d3Coordinate.x, d3Coordinate.y))
.force('collide', d3.forceCollide().radius(radius + 20))
.on('tick', ticked)
...
}
上面就是使用数据初始化力模型的方法,具体的方法解释可以查看文档 这里说一下forceCenter,文档中是这样解释的:
center(向心力) 可以将所有的节点的中心统一整体的向指定的位置 ⟨x,y⟩ 移动。
d3Coordinate.x, d3Coordinate.y这个是我计算的canvas的中间点的坐标,这段的意思就是我把所有节点的中心统一整体的移动到了canvas视口中心位置。
在始化的时候,还应用了一个力模型d3.forceLink(),由于文档中指明
如果指定的 links 数组被修改,比如添加或者移除边,这个方法必须使用新的数组重新调用一次,这个方法不会创建指定数组的副本。
所以我封装成方法,在改变数据的时候调用,有的同学会问,你不是不涉及增删改么,那就只有加载时一次请求,不会涉及到数据更改呀,确实不涉及,但是这里还有一个功能点,就是点击节点会有探索的功能,所以节点和关系的数量可能会发生变化,所以就会涉及到多处多次调用的情况。
function setForceLink() {
simulation.force(
'link',
d3
.forceLink(links.value)
.id(
({ index }) =>
(d3.map(nodes.value, ({ rid }) => rid) as string[])[index!]
)
.distance(400)
)
}
到此为止,初始化应用力模型的部分到此就完了。
力模型初始化完成之后,下面为每个节点绑定拖拽、缩放和hover事件
function init() {
...
d3.select(canvas)
.call(
d3
.drag()
.container(canvas)
.subject(function (e: DragEvent) {
const x = transform.invertX(e.x)
const y = transform.invertY(e.y)
let r = radius * radius
let dx, dy
for (let i = 0; i < nodes.value.length; i++) {
const node = nodes.value[i]
dx = x - node.x!
dy = y - node.y!
if (dx * dx + dy * dy < r) {
return node
}
}
return null
})
.on('start', dragstart)
.on('drag', drag)
.on('end', dragend) as any
)
.call(zoom)
.on('mousemove', showToolTip)
}
d3.select().call() 作用是为当前选择集指向相应的函数。这里的意思就是为canvas画布应用拖拽方法和缩放方法。
d3.select().on('mousemove', showToolTip)是添加或者移除事件监听器,这里是为鼠标移入是增加监听。
d3.drag().container().subject()的作用是设置被拖拽的主体,由于是使用canvas实现,所以需要根据鼠标的位置和节点的位置判断鼠标是否在节点范围内,subject内部的实现就是返回鼠标当前所处的节点。
这里还为canvas增加了缩放和拖拽方法即zoom,实现如下:
const zoom: any = d3
.zoom()
.scaleExtent([0.5, 4])
.on('zoom', (e: any) => {
transform = e.transform
render()
})
transform缓存起来是为了后边放大和缩小的时候canvas使用。
说到这里就要说一下render() 这是渲染的核心函数,所以先看一下代码:
function render() {
context.setTransform(1, 0, 0, 1, 0, 0)
context.clearRect(0, 0, window.innerWidth, window.innerHeight)
context.setTransform(transform.k, 0, 0, transform.k, transform.x, transform.y)
links.value.forEach(function (l: any) {
const xy = getPoint(l.source.x, l.source.y, l.target.x, l.target.y, 50)
const txy = getPoint(l.target.x, l.target.y, l.source.x, l.source.y, 50)
context.beginPath()
context.moveTo(xy.x, xy.y)
context.lineTo(txy.x, txy.y)
context.strokeStyle = '#505A6C'
context.lineWidth = 1
context.stroke()
const diff_x = txy.x - xy.x,
diff_y = txy.y - xy.y
let angel = Math.atan2(txy.y - xy.y, txy.x - xy.x)
if ((diff_x < 0 && diff_y > 0) || (diff_x < 0 && diff_y < 0)) {
angel = angel + Math.PI
}
let angle = Math.atan2(txy.y - xy.y, txy.x - xy.x)
const midX = (txy.x + xy.x) / 2
const midY = (txy.y + xy.y) / 2
const arrowSize = 10
context.save()
context.translate(midX, midY)
context.rotate(angel)
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = 'black'
context.fillText(l.value, 0, -20)
context.restore()
context.beginPath()
context.moveTo(txy.x, txy.y)
context.lineTo(
txy.x - arrowSize * Math.cos(angle - Math.PI / 6),
txy.y - arrowSize * Math.sin(angle - Math.PI / 6)
)
context.lineTo(
txy.x - arrowSize * Math.cos(angle + Math.PI / 6),
txy.y - arrowSize * Math.sin(angle + Math.PI / 6)
)
context.closePath()
context.fillStyle = '#505A6C'
context.strokeStyle = '#505A6C'
context.fill()
context.stroke()
})
nodes.value.forEach(function (n: any) {
const x = n.x
const y = n.y
context.fillStyle = n.color
context.beginPath()
context.arc(x, y, 32, 0, 2 * Math.PI, true)
context.strokeStyle = 'white'
context.lineWidth = 2
context.fill()
context.fillStyle = '#fff'
context.font = '14px Arial'
context.textAlign = 'center'
if (context.measureText(n.name).width <= radius * 2) {
context.fillText(n.name, x, y + 6)
} else {
let max = radius * 2 - 20
let line1 = ''
let line2 = ''
for (var i = 0; i < n.name.length; i++) {
if (
context.measureText(line1 + n.name[i]).width <= max &&
i < n.name.length - 1
) {
line1 += n.name[i]
} else {
if (context.measureText(line2 + n.name[i]).width <= max) {
line2 += n.name[i]
}
}
}
line2 = line2.trim()
line2 = line2.substr(0, line2.length - 1) + '...'
context.fillText(line1, x, y - 4)
context.fillText(line2, x, y + 14)
}
context.stroke()
})
context.restore()
}
注意方法开头的这两行代码:
context.setTransform(1, 0, 0, 1, 0, 0)
context.clearRect(0, 0, window.innerWidth, window.innerHeight)
这里当时卡了我半天才解决,可能是我对canvas了解不深入哈哈。 之前遇到的情况是这样的,在我缩小页面之后,节点运动到缩小后的canvas画布外的时候,出现重影的情况,即使清除尺寸设置为window的宽高也无效,试过很多方法,甚至试过替换canvas,都不能解决,后来google的时候,发现有人提出这种方法,试了一下,果然奏效。 原理就是我在下一次绘制之前,先把画布还原为原本大小,再清除画布然后再重新绘图,这样既可以避免画布外重影的情况。
到这里,图谱的绘制就已经完成,下边再贴一下拖动的代码:
function dragstart(e: any) {
if (!e.active) simulation && simulation.alphaTarget(0.3).restart()
e.subject.fx = e.x + canvasOrigin.currOffset.x
e.subject.fy = e.y + canvasOrigin.currOffset.y
e.sourceEvent.stopPropagation()
}
function drag(event: any) {
hasMove = true
event.subject.fx = event.x + canvasOrigin.currOffset.x
event.subject.fy = event.y + canvasOrigin.currOffset.y
}
async function dragend(event: any) {
if (hasMove) {
if (!event.active) simulation && simulation.alphaTarget(0)
event.subject.fx = undefined
event.subject.fy = undefined
hasMove = false
} else {
const { data } = await ... // 这里是请求数据
const { nodes, links } = buildViewData(data)
update(nodes, links, data.labelsList, data.relationShipList)
}
}
update方法主要是实现探索功能,点击某个节点的时候,会请求该节点下的子节点,并且渲染到canvas,思路就是在点击请求到数据之后,遍历节点和关系数据,在老的数据中查找,找到了就什么都不做,因为返回的节点可能会重复,所以不需要更新,没找到则要把新的节点和关系数据push到旧的数据中心,在调用render渲染一下。
以上就是实现一个图谱渲染、拖拽和探索的思路,代码可能写的不够严谨,还有很多可以优化的地方,希望大家多多指出,一起学习,本文纯属个人观点,仅供参考。