一、前言
之前的博客:对Neo4j导出数据做知识图谱可视化 D3库实现
之前博客做的知识图谱可视化只是在html上直接写的页面,而且d3还使用的是v4版本的库。
但是当项目复杂的情况下(前端项目越来越复杂了),比如要在系统中加入其他图表进行综合大屏展示,显然不是一个很好的选择,于是决定用vue重构一下代码,把图可视化作为一个组件来维护,顺便再用一下新版本的d3。同时之前的项目存在一个致命的问题,就是动态更新,即当页面已经有图谱展示的情况下,查询新的数据时,无法正常展示(节点全跑到左上角去了)
下图是向基于nodejs搭建neo4j后端服务请求数据后,更新视图出现的问题:(Vue重构后,代码不变的情况下可以解决该问题)
二、d3@v6相关链接参考
下面附上供学习和参考的链接:
- D3官方网站
- D3 npm包地址
- D3学习文档
- D3 Tutorials
- D3 Graph Theory
- D3新版本改动官方说明文档
- D3 案例参考
- 官方力导向图DEMO参考(svg)
- 官方力导向图DEMO参考(canvas)
- 右键事件绑定DEMO
- github关于右键菜单的插件库参考
目前d3已经到v6版本了(更新是真滴快),关于新版d3的改动说明的链接我放在了上面供参考。虽然更新了很多东西,但总的来说原来的代码其实改动不大,比如新版的d3移除了d3.event,v4版本需要注意。
三、代码详细介绍
1. 页面结构(2021年5月更新)
首先页面的整体结构如下,分为2D和3D图谱展示两个页面,其中2D知识图谱除了展示还实现了各种图交互功能;而3D知识图谱采用了第三方模块,底层使用 D3+Three 实现视图渲染,直接截图展示:
2. 代码结构(2021年5月新增)
这次对代码结构作了大改,变化挺大的,文件夹对应关系如下:
- 组件存放在
components
文件夹d3graph.vue
—— 2D图谱展示组件threeGraph.vue
—— 3D图谱展示组件gSearch.vue
—— 搜索组件,目前主要通过require代替后台请求
- 页面存放在
views
文件夹2dView.vue
—— 2D图谱展示页面3dView.vue
—— 3D图谱展示页面
- d3插件存放在
plugins
文件夹d3-context-menu.js
—— 右键菜单事件注册及回调函数d3-context-menu.scss
—— 右键菜单样式文件
- 路由存放在
router
文件夹index.js
—— 路由较少,就2个页面
- 静态图数据存放在
data
文件夹 store
和assets
文件夹暂时不用
3. 功能及代码介绍
接下来介绍关于图可视化的基本功能与交互事件:
1)d3初始化(2021年5月更新)
d3初始化,包括数据解析、数据渲染及响应式数据初始化,在新版本代码将数据解析分离出来,放到 2dView.vue
页面中 。现在d3初始化分为数据渲染和状态初始化两个任务。
旧版本:
// d3初始化,包括数据解析、数据渲染
d3init () {
// this.graph存放json数据
this.d3jsonParser(this.graph)
this.d3render()
// 数据初始化(节点状态)
this.nodeState = 0
this.states = ['on', 'on', 'on', 'on']
}
新版本:
// d3初始化,包括数据解析、数据渲染
d3init () {
this.links = this.data.links
this.nodes = this.data.nodes
this.svgDom = d3.select('#svg') // 获取svg的DOM元素
// this.d3jsonParser(this.graph)
this.d3render()
// 数据状态初始化
this.stateInit()
},
- d3jsonParser:对请求的json数据进行解析,分别格式化为节点和关系数据,解析后存放到 this.links(关系显示数据) / this.nodes(节点显示数据) / this.data(全部图数据)
d3jsonParser (json) {
const nodes =[]
const links = [] // 存放节点和关系
const nodeSet = [] // 存放去重后nodes的id
// 使用vue直接通过require获取本地json,不再需要使用d3.json获取数据
// d3.json('./../data/records.json', function (error, data) {
// if (error) throw error
// graph = data
// console.log(graph[0].p)
// })
for (let item of json) {
for (let segment of item.p.segments) {
// 重新更改data格式
if (nodeSet.indexOf(segment.start.identity) == -1) {
nodeSet.push(segment.start.identity)
nodes.push({
id: segment.start.identity,
label: segment.start.labels[0],
properties: segment.start.properties
})
}
if (nodeSet.indexOf(segment.end.identity) == -1) {
nodeSet.push(segment.end.identity)
nodes.push({
id: segment.end.identity,
label: segment.end.labels[0],
properties: segment.end.properties
})
}
links.push({
source: segment.relationship.start,
target: segment.relationship.end,
type: segment.relationship.type,
properties: segment.relationship.properties
})
}
}
console.log(nodes)
console.log(links)
this.links = links
this.nodes = nodes
this.data = { nodes, links }
// return { nodes, links }
}
- d3render代码部分较多,包括了DOM挂载、事件绑定、以及D3视图渲染,在下面一一进行介绍
2)图查询更新视图(2021年5月更新)
该按钮模拟了向后台请求数据的功能,获取新的数据后,更新图展示部分,重新渲染视图,解决了原博客在动态更新这一块的问题。
旧版(暂时保留图片):
目前查询暂用require本地数据代替后台请求数据,gSearch.vue
将数据传给页面,并执行数据解析的任务;而 d3graph.vue
组件通过 watch
监听当前的数据变化,更新后通过 this.d3init() 重新渲染页面。
gSearch.vue
模拟后台查询:
query () {
// console.log(typeof this.mode)
if (this.data.length <= 20) {
this.data = require('../data/top5.json')
} else {
this.data = require('../data/records.json')
}
this.$emit('getData', this.data)
}
d3graph.vue
监听数据变化:
watch: {
// 当请求到新的数据时,重新渲染
data (newData, oldData) {
console.log(newData, oldData)
// 移除svg和元素注册事件,防止内存泄漏
this.svgDom.on('.', null)
this.svgDom.selectAll('*').on('.', null)
this.d3init()
}
}
3)平移与缩放(2021年5月更新)
- 按住鼠标在空白位置移动可以移动视图的相对位置
- 鼠标滚轮完成视图的放大和缩小
下图展示将视图通过缩放、拖拽和平移来调整图谱布局,使展示结果更加清晰。
事件直接在svg视图部分注册,通过标签的translate和scale属性实现平移和缩放:
var svg = d3.select("#svg1")
// 给画布绑定zoom事件(缩放、平移)
.call(d3.zoom().on('zoom', function(event) {
// console.log(event)
var scale = event.transform.k,
translate = [event.transform.x, event.transform.y]
// 视图矫正,暂不使用
// if (this.svgTranslate) {
// translate[0] += this.svgTranslate[0]
// translate[1] += this.svgTranslate[1]
// }
// if (this.svgScale) {
// scale *= this.svgScale
// }
svg.attr('transform', 'translate(' + translate[0] + ', ' + translate[1] + ') scale(' + scale + ')');
}))
.append('g')
.attr('width', '100%')
.attr('height', '100%')
4)文字显示
文字基于节点居中显示,并实现自动换行排版,根据文本字数分1-3行进行展示:
- 单行(3个字内)
- 两行(4-7个字)
- 三行(8-10,10字以上隐藏)
函数实现(通过attr属性进行批量操作)
/**
* 文本分隔(根据字数在当前选择器中分隔三行,超过10字省略)
* @method textBreaking
* @param {d3text} 文本对应的DOM对象
* @param {text} 节点名称的文本值
* @return {void}
*/
function textBreaking(d3text, text) {
const len = text.length
if (len <= 3) {
d3text.append('tspan')
.attr('x', 0)
.attr('y', 2)
.text(text)
} else {
const topText = text.substring(0, 3)
const midText = text.substring(3, 7)
let botText = text.substring(7, len)
let topY = -16
let midY = 0
let botY = 16
if (len <= 7) {
topY += 10
midY += 10
} else if (len > 10){
botText = text.substring(7, 9) + '...'
}
d3text.text('')
d3text.append('tspan')
.attr('x', 0)
.attr('y', topY)
.text(function () {
return topText
})
d3text.append('tspan')
.attr('x', 0)
.attr('y', midY)
.text(function () {
return midText
})
d3text.append('tspan')
.attr('x', 0)
.attr('y', botY)
.text(function () {
return botText
})
}
}
5)拖拽
通过按住鼠标调整选中节点的位置及相对布局,注意v6版本事件移除了全局事件对象d3.event,通过返回的函数参数来处理。
node.call(this.drag(simulation))
text.call(this.drag(simulation))
// 定义在vue的methods中
drag(simulation) {
function dragsubject(event) {
return simulation.find(event.x, event.y);
}
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
// 注释以下代码,使拖动结束后固定节点
// event.subject.fx = null;
// event.subject.fy = null;
}
return d3.drag()
.subject(dragsubject)
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended)
}
6)鼠标浮动事件(2021年5月更新)
鼠标悬浮在节点上触发,并通过粗边框标记选中节点,细边框标记关联的节点,同时在右侧展示该节点的详细信息。
旧版效果:
注册悬浮事件时,注意_this
指向Vue实例(全局定义),this
指向当前的d3元素:
node.on('mouseenter', function (event) {
const node = d3.select(this)
//获取被选中元素的名字
let name = node.attr("name")
let id = node.attr("id")
let color = node.attr('fill')
console.log(name, id, color)
//设置#info h4样式的颜色为该节点的颜色,文本为该节点name
_this.$set(_this.selectNodeData, 'id', id)
_this.$set(_this.selectNodeData, 'name', name)
_this.$set(_this.selectNodeData, 'color', color)
//遍历查找id对应的属性
for (let item of _this.nodes) {
if (item.id == id) {
// for(var key in item.properties)
_this.$set(_this.selectNodeData, 'properties', item.properties)
}
}
// 选择#svg1 .nodes中所有的circle,再增加个class
d3.select('#svg1 .nodes').selectAll('circle').attr('class', function(d) {
// 节点属性name是否等于name,返回fixed(激活选中样式)
if(d.properties.name == name) {
return 'fixed'
}
// 当前节点返回空,否则其他节点循环判断是否被隐藏起来(CSS设置隐藏)
else {
// links链接的起始节点进行判断,如果其id等于name则显示这类节点
// 注意: graph=data
for (var i = 0; i < _this.links.length; i++) {
//如果links的起点等于name,并且终点等于正在处理的则显示
if (_this.links[i]['source'].properties.name == name && _this.links[i]['target'].id == d.id) {
return ''
}
if (_this.links[i]['target'].properties.name == name && _this.links[i]['source'].id == d.id) {
return ''
}
}
return "inactive" // 前面CSS定义 .nodes circle.inactive
}
})
//处理相邻的边line是否隐藏 注意 ||
d3.select("#svg1 .links").selectAll('line').attr('class', function(d) {
if (d.source.properties.name == name || d.target.properties.name == name) {
return ''
} else {
return 'inactive'
}
})
})
7)鼠标单击事件(2021年5月更新)
- 单击当前节点,进入聚焦模式,此时会隐藏无关节点并标记当前选中节点及邻居节点和关系;
- 点击画布即可取消聚焦模式。
8)鼠标右键菜单(写一个d3插件)
鼠标右击目标节点触发,目前实现了三个菜单功能
- 隐藏节点(隐藏选中节点及其关系)
- 显示节点关联图(显示与选中节点关联的其他节点和关系)
- 显示所有查询节点(显示查询的所有节点)
由于d3没有相关的菜单功能,需要自己手动写插件挂载到d3上,我们把菜单的功能和样式放到plugins文件夹中,并在vue组件中引入。
全局注册d3插件:
import * as d3 from 'd3'
import install from '@/plugins/d3-context-menu'
install(d3) // 为d3注册右键菜单插件
style中引入菜单的样式:
@import '@/plugins/d3-context-menu';
给节点注册菜单事件:
// 注册菜单事件
// d3.contextMenu是全局自定义的插件功能
node.on('contextmenu', d3.contextMenu(this.menu))
9)图例交互事件
通过点击图例,隐藏对应类型的节点和关系,如图点击后隐藏了贸易类型和国家两种类型的节点及对应关系。
// 隐藏该类型的所有节点
hideNodeOfType (event) {
// console.log(event.target.dataset)
const index = event.target.dataset.index
const state = event.target.dataset.state
const nodeTypes = ['Enterprise', 'Type', 'Region', 'Country']
const linkTypes = ['', 'type', 'locate', 'export']
// 图例的状态切换(对应类型的节点隐藏)
if (state === 'on') {
// 隐藏该类型的所有节点及关联关系
// this.states[index] = 'off'
this.$set(this.states, index, 'off')
} else {
// this.states[index] = 'on'
this.$set(this.states, index, 'on')
}
/**************************************
* 状态更新后,同时对数据更新
*/
const indexs = this.states.map(s => {
if (s === 'on') {
return '1'
} else {
return '0'
}
})
// 遍历删除节点
this.nodes = this.data.nodes.filter(node => {
for (let i = 0; i < indexs.length; i++) {
if (node.label === nodeTypes[i] && indexs[i] === '0') return false
}
return true
})
// 遍历删除关系
this.links = this.data.links.filter(link => {
for (let i = 0; i < indexs.length; i++) {
if (i === 0 && indexs[i] === '0') return false
else if (link.type === linkTypes[i] && indexs[i] === '0') return false
}
return true
})
// 重新渲染
this.d3render()
}
10)文字搜索(2021年5月更新)
图数据量过大时,可以通过左侧的关键字搜索,找到符合条件的节点及关联节点
// 搜索包含关键字的节点
searchKeyWords (value) {
console.log('keyup event! => ' + value)
// 如果Input值是空的显示所有的圆和线(没有进行筛选)
if (this.keywords === '') {
// d3.select('#svg1 .texts').selectAll('text').attr('class', '')
d3.select('#svg1 .nodes').selectAll('circle').attr('class', '')
d3.select('#svg1 .links').selectAll('line').attr('class', '')
}
// 否则判断判断三个元素是否等于name值,等于则显示该值
else {
var name = this.keywords
// 搜索所有的节点
d3.select('#svg1 .nodes').selectAll('circle').attr('class', (d) => {
// 输入节点id的小写等于name则显示,否则隐藏
if (d.properties.name.indexOf(name) >= 0) {
return ''
} else {
// 优化:与该搜索节点相关联的节点均显示
// links链接的起始节点进行判断,如果其id等于name则显示这类节点
// 注意: graph=data
for (var i = 0; i < this.links.length; i++) {
// 如果links的起点等于name,并且终点等于正在处理的则显示
if ((this.links[i]['source'].properties.name.indexOf(name) >= 0) &&
(this.links[i]['target'].id == d.id)) {
return ''
}
// 如果links的终点等于name,并且起点等于正在处理的则显示
if ((this.links[i]['target'].properties.name.indexOf(name) >= 0) &&
(this.links[i]['source'].id == d.id)) {
return ''
}
}
return 'inactive' // 隐藏
}
})
// 搜索links
// 显示相的邻边 注意 ||
d3.select("#svg1 .links").selectAll('line').attr('class', (d) => {
if ((d.source.properties.name.indexOf(name) >= 0) ||
(d.target.properties.name.indexOf(name) >= 0)
) {
return ''
} else {
return 'inactive' //隐藏
}
})
}
}
11)模式选择(2021年5月更新)
显示或隐藏关系的文字信息,这里就不截图了。通过节点状态(显示/隐藏)判断是否展示文字部分:
// 隐藏文字
changeTextState (state) {
// state发生变化时才进行更新、处理
if (this.textState !== state) {
this.textState = state
// const text = d3.selectAll('.texts text')
const text = d3.selectAll('.linkTexts text')
console.log(text)
// 根据新的节点状态,在节点上展示不同的文本信息
if (this.textState === 2) {
text.style('display', 'none')
// 暂不作校准
// // transform属性数值化
// // 原:translate(40, 8) scale(1)
// // 现:[40, 8, 1]
// let transform = d3.select('#svg1 g').attr('transform')
// transform = transform
// ? transform.match(/\d.?/g).map(item => parseInt(item))
// : [0, 0, 1]
// // 校准
// transform[0] = transform[0] + this.svgTranslate[0]
// transform[1] = transform[1] + this.svgTranslate[1]
// transform[2] = transform[2] * this.svgScale
// console.log(transform)
// // 隐藏节点后,svg自动缩放
// d3.select('#svg1 g').attr('transform', 'translate(' + transform[0] + ', ' + transform[1] + ') scale(' + transform[2] + ')')
} else {
text.style('display', 'block')
// 暂不作校准
// 显示节点后,svg自动还原
// d3.select('#svg1 g').attr('transform', '')
}
}
}
12)关系文字和箭头的绘制与调整(2021年5月新增)
根据关系直线的坐标位置对文字进行调整:
- 第一象限
- 第二象限
- 第三象限
- 第四象限
添加关系文字和箭头(代码在d3render ()
函数中)
var link = svg.append("g")
.attr("class", "links")
.selectAll("line")
.data(this.links).enter()
.append("line")
.attr("stroke-width", function(d) {
// 每次访问links的一项数据
return 2 //所有线宽度均为2
})
.join("path")
.attr("marker-end", "url(#posMarker)")
var linksName = svg.append("g")
.attr("class", "linkTexts")
.selectAll("text")
.data(this.links)
.join("text")
.style('text-anchor','middle')
.style('fill', '#fff')
.style('font-size', '12px')
// .style('font-weight', 'bold')
.text(d => d.properties.name)
关系文字动态调整位置(代码在d3render ()
的内部函数 ticked()
中):
// ticked()函数确定link线的起始点x、y坐标 node确定中心点 文本通过translate平移变化
function ticked() {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y)
linksName
.attr('transform', d => {
let x = Math.min(d.source.x, d.target.x) + Math.abs(d.source.x - d.target.x) / 2
let y = Math.min(d.source.y, d.target.y) + Math.abs(d.source.y - d.target.y) / 2 - 1
// tanA = a / b
// A = arctan(tanA)
let tanA = Math.abs(d.source.y - d.target.y) / Math.abs(d.source.x - d.target.x)
let angle = Math.atan(tanA) / Math.PI * 180
// let angle = Math.atan2(1,1)/Math.PI*180
// console.log(angle)
// 第一、二象限额外处理
if (d.source.x > d.target.x) {
// 第二象限
if (d.source.y <= d.target.y) {
angle = -angle
}
// else { // 第三象限
// angle = angle
// }
} else if (d.source.y > d.target.y) {
// 第一象限
angle = -angle
}
return 'translate(' + x + ',' + y + ')' + 'rotate(' + angle + ')'
})
node
.attr("cx", d => d.x)
.attr("cy", d => d.y)
text.attr('transform', function(d) {
let size = 15
switch(d.label){
case _this.labels[0]: break;
case _this.labels[1]: size = 14;break;
case _this.labels[2]: size = 13;break;
default: size = 12;break;
}
size -= 5
return 'translate(' + (d.x - size / 2 + 3) + ',' + (d.y + size / 2) + ')'
})
}
绘制关系箭头:目前只绘制了正向关系箭头(激活和非激活样式),逆向关系箭头未生效,需要的请自行修改下面的注释代码。
// 绘制关系箭头
addMarkers() {
// 定义箭头的标识
var defs = this.svgDom.append("defs")
const posMarker = defs.append("marker")
.attr("id", "posMarker")
.attr("orient", "auto")
.attr("stroke-width", 2)
.attr("markerUnits", "strokeWidth")
.attr("markerUnits", "userSpaceOnUse")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 31)
.attr("refY", 0)
.attr("markerWidth", 12)
.attr("markerHeight", 12)
.append("path")
.attr("d", "M 0 -5 L 10 0 L 0 5")
.attr('fill', '#e0cac1')
.attr("stroke-opacity", 0.6);
const posActMarker = defs.append("marker")
.attr("id", "posActMarker")
.attr("orient", "auto")
.attr("stroke-width", 2)
.attr("markerUnits", "strokeWidth")
.attr("markerUnits", "userSpaceOnUse")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 31)
.attr("refY", 0)
.attr("markerWidth", 12)
.attr("markerHeight", 12)
.append("path")
.attr("d", "M 0 -5 L 10 0 L 0 5")
.attr('fill', '#1E90FF')
.attr("stroke-opacity", 0.6);
// const negMarker = defs.append("marker")
// .attr("id","negMarker")
// .attr("orient","auto")
// .attr("stroke-width",2)
// .attr("markerUnits", "strokeWidth")
// .attr("markerUnits", "userSpaceOnUse")
// .attr("viewBox", "0 -5 10 10")
// .attr("refX", -25)
// .attr("refY", 0)
// .attr("markerWidth", 12)
// .attr("markerHeight", 12)
// .append("path")
// .attr("d", "M 10 -5 L 0 0 L 10 5")
// .attr('fill', '#999')
// .attr("stroke-opacity", 0.6);
}
13)节点编辑功能(2021年5月新增)
目前完成了纯前端的节点编辑功能,如果想集成后端需要自行添加代码。通过点击编辑按钮可以修改属性对应的文本内容,再点击确定使其生效。
// 编辑当前选中节点
btnEdit () {
this.temp = Object.assign({}, this.selectNodeData.properties) // copy obj
this.dialogFormVisible = true
console.log(this.selectNodeData)
},
doEdit () {
// console.log(this.data)
let i = 0
// 更新props的data 和 selectNodeData
this.selectNodeData.name = this.temp.name
this.selectNodeData.properties = this.temp
for (let node of this.data.nodes) {
// console.log(node.id === this.selectNodeData.id)
// console.log(node.id)
// console.log(this.selectNodeData.id)
if (node.id == this.selectNodeData.id) {
// this.$set(this.data.nodes, i, this.selectNodeData)
// this.$set(this.nodes, i, this.selectNodeData)
this.data.nodes[i].properties = this.temp
this.nodes[i].properties = this.temp
break
}
i++
}
this.dialogFormVisible = false
this.d3init()
this.$message({
message: '更新成功',
type: 'success'
})
},
cancelEdit () {
this.dialogFormVisible = false
}
14)3D图谱展示(2021年5月新增)
- 底层使用D3+Three完成渲染
- 具体参考 3D知识图谱可视化 这篇博客
四、个人代码参考
目前已经实现了图谱展示的基本功能,可以根据代码自行拓展,以下是代码地址,水平有限,仅提供参考:
-
Github代码地址:https://github.com/CoderWanp/vue-d3-graph
-
CSDN下载地址:https://download.csdn.net/download/tiandao451/20119078
-
目前的展示效果图
-
新增3D图谱展示页面,已添加到代码中
五、总结
目前代码存在的问题:
菜单功能有BUG,隐藏节点后再显示当前节点关联图会出现节点缺失问题没有展示关系文本没有箭头,表示关系的指向- 右键菜单功能和样式待优化
- 不支持多关系
到这里其实博客差不多就算完结了,这篇博客和代码的工作量其实比我想象的要大,真的花了很多精力去做这个,前前后后改了好几个版本,现在其实基本功能已经差不多都实现了,再去拓展比自己写要容易很多。
代码改动相比老版本较大,可能会存在一些问题,但是写博客好累,特别是这种一个字一个字打,打完又改了好几遍的。目前短时间内不太想往下做了,如果后面有时间的话,会回来再完善一下代码的功能。
由于最近发现点击Github地址会跳转到第三方盗用代码的下载链接,目前该下载还需要VIP,并且无法举报,所以我把代码上传到CSDN资源上了。如果无法打开Github可以选择从上面的CSDN地址进入下载。