d3力导向图
1. html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>d3力导向图</title>
<script src='http://d3js.org/d3.v4.min.js' charset='utf-8'></script>
<script src='./draw.js' charset='utf-8'></script>
<script src='./dataCollation.js' charset='utf-8'></script>
</head>
<body>
<div id="canvas" style="height:600px; width: 1000px;"></div>
<script>
// 图表数据
var json = {
nodes: [
{ id: 1, labels: 'WIFI', name: 'WIFI1' },
{ id: 2, labels: 'WIFI', name: 'WIFI2' },
{ id: 3, labels: 'WIFI', name: 'WIFI3' },
{ id: 4, labels: 'WIFI', name: 'WIFI4' },
{ id: 5, labels: 'WIFI', name: 'WIFI5' },
{ id: 6, labels: 'WIFI', name: 'WIFI6' }
],
edges: [
{ id: 101, source: 1, target: 2, types: 'GPS' },
{ id: 102, source: 1, target: 3, types: 'GPS' },
{ id: 103, source: 1, target: 4, types: 'GPS' },
{ id: 104, source: 4, target: 5, types: 'GPS' },
{ id: 105, source: 6, target: 3, types: 'GPS' },
{ id: 106, source: 6, target: 3, types: 'GPS' },
{ id: 107, source: 6, target: 3, types: 'GPS' }
]
}
// 初始化
initgraph()
function initgraph() {
// 创建svg视图
var vis = buildVis()
// 力导向图布局
var force = buildForce()
// 连接线层
var linkGroup = vis.append('g').attr('class', 'linkGroup')
// 连接线文字层
var linktextGroup = vis.append('g').attr('class', 'linktextGroup')
// 节点层
var nodeGroup = vis.append('g').attr('class', 'nodeGroup')
// 数据备份
for (const i in json.nodes) {
json.nodes[i].entire = JSON.parse(JSON.stringify(json.nodes[i]))
}
var linkmap = {}
var lGroup = {}
json.edges = collationLinksData(json.edges, linkmap, lGroup)
// 装载数据更新图
update(json)
function update(json) {
var lks = json.edges
var nodes = json.nodes
var links = []
lks.forEach((m) => {
var sourceNode = nodes.filter((n) => { return n.id === m.entire.source })[0]
if (typeof (sourceNode) === 'undefined') return
var targetNode = nodes.filter((n) => { return n.id === m.entire.target })[0]
if (typeof (targetNode) === 'undefined') return
links.push({ source: sourceNode.id, target: targetNode.id, entire: m.entire, id: m.id, linknum: m.linknum })
})
json.edges = links
// 整理节点
force.nodes(json.nodes)
// 整理连线
force.force('link').links(json.edges)
// 画连线
var link = buildLink(json, linkGroup)
// 画连线文字
var linetext = buildLinktext(json, linktextGroup)
// 画节点
var node = buildNode(json, nodeGroup)
// 绑定节点点击事件
var nodeClick = bindNodeClick(node)
// 绑定拖拽
node.call(nodeDrag(force))
force.on('tick', () => (buildTick(link, node, linetext)))
force.alphaTarget(0).restart()
// 向前推动力导向,确定节点位置坐标
advance(force, 1000)
// 停止力导向
force.stop()
buildTick(link, node, linetext)
}
}
</script>
</body>
</html>
2. draw.js
// 创建画布
function buildVis() {
d3.select('#canvas').select('*').remove()
const zoom = d3.zoom().scaleExtent([0.01, 5]).on('zoom', () => { vis.attr('transform', () => (d3.event.transform)) })
const vis = d3.select('#canvas')
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.call(zoom)
.on('dblclick.zoom', null)
.append('g')
.attr('class', 'all')
// 箭头
vis.append('marker')
.attr('id', 'arrow')
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#fce6d4')
// 图标
const path = 'm2.37978,3.80176c-0.71942,-0.38369 -0.33573,-1.10311 0,-1.43883c0.43165,-0.43165 0.95922,-1.91845 0.95922,-1.91845c0.8633,-0.38369 0.95922,-1.00718 1.05515,-1.43883c0.38369,-1.24699 -0.57553,-1.43883 -0.57553,-1.43883s0.76738,-2.06233 0.14388,-3.64505c-0.81534,-2.06233 -4.12466,-2.82971 -4.70019,-0.91126c-3.93281,-0.8633 -3.11747,4.55631 -3.11747,4.55631s-0.95922,0.19184 -0.57553,1.43883c0.09592,0.43165 0.19184,1.05515 1.05515,1.43883c0,0 0.52757,1.4868 0.95922,1.91845c0.33573,0.33573 0.71942,1.05515 0,1.43883c-1.43883,0.76738 -5.75534,0.95922 -5.75534,4.3165l16.30679,0c0,-3.35728 -4.3165,-3.54912 -5.75534,-4.3165z'
vis.append('defs').append('g').attr('id', 'user').append('path').attr('d', path)
return vis
}
// 创建力布局
function buildForce() {
return d3.forceSimulation()
.force('link', d3.forceLink().distance(200).id((d) => { return d.id }))
.force('charge', d3.forceManyBody().strength(-400))
// 力的中心点
.force('center', d3.forceCenter(500 / 2, 500 / 2))
.force('collide', d3.forceCollide().strength(-30))
}
// 创建连线
function buildLink(json, linkGroup) {
let link = linkGroup.selectAll('.line').data(json.edges, (d) => { return d.entire.id })
link.exit().remove()
link = link.enter().append('path')
.attr('stroke-width', 2)
.attr('class', 'line')
.style('stroke', '#ccc')
.style('cursor', 'pointer')
.attr('fill', 'none')
.attr('id', (d) => { return 'line-' + d.entire.id + '-source-' + d.entire.source + '-target-' + d.entire.target })
.attr('marker-end', 'url(#arrow)')
.merge(link)
return link
}
// 创建连线文字
function buildLinktext(json, linktextGroup) {
let linkText = linktextGroup.selectAll('text').data(json.edges, (d) => { return d.entire.id })
linkText.exit().remove()
linkText = linkText.enter().append('text')
.attr('class', (d) => { return 'linkText-' + d.entire.types })
.attr('id', (d) => { return 'linkText-' + d.entire.id + '-source-' + d.entire.source + '-target-' + d.entire.target })
.attr('dy', -5)
.style('font-size', 12)
.merge(linkText)
linkText.selectAll('.textPath').remove()
linkText.append('textPath')
.attr('startOffset', '45%')
.attr('class', (d) => { return 'textPath linetext-' + d.entire.types })
.attr('xlink:href', (d) => { return '#line-' + d.entire.id + '-source-' + d.entire.source + '-target-' + d.entire.target })
.text((d) => { return d.entire.id })
return linkText
}
// 创建节点
function buildNode(json, nodeGroup) {
let node = nodeGroup.selectAll('.node')
node = node.data(json.nodes, (d) => { return d.id })
node.exit().remove()
node = node.enter()
.append('g')
.attr('class', 'node')
.attr('id', (d) => { return 'node-' + d.id })
.merge(node)
node.selectAll('.node-bg').remove()
node.selectAll('.node-icon').remove()
node.selectAll('.node-text').remove()
// 背景圆
node.append('circle')
.attr('class', 'node-bg')
.attr('id', (d) => { return 'nodeBg-' + d.id })
.attr('r', 24)
.style('fill', '#C1C1C1')
// .style('fill-opacity', 0.3)
// 图标
node.append('use')
.attr('class', 'node-icon')
.attr('id', (d) => { return 'nodeIcon-' + d.id })
.attr('r', 24)
.style('fill', '#FFFFFF')
.attr('xlink:href', '#user')
// 文字
node.append('text')
.attr('class', 'node-text')
.attr('id', (d) => { return 'nodeText-' + d.id })
.attr('dy', 40)
.attr('text-anchor', 'middle')
.text((d) => { return d.entire.id })
return node
}
// 节点拖拽
function nodeDrag(force) {
// 开始拖拽
const dragstart = () => {
if (!d3.event.active) force.alphaTarget(0.3).restart()
d3.event.subject.fx = d3.event.subject.x
d3.event.subject.fy = d3.event.subject.y
}
// 正在拖拽
const dragmove = () => {
d3.event.subject.fx = d3.event.x
d3.event.subject.fy = d3.event.y
}
// 结束拖拽
const dragend = () => {
if (!d3.event.active) force.stop()
}
return d3.drag().on('start', dragstart).on('drag', dragmove).on('end', dragend)
}
// 更新坐标
function buildTick(link, node, linetext) {
link.attr('d', (d) => {
// 如果连接线连接的是同一个实体,则对path属性进行调整,绘制的圆弧属于长圆弧,同时对终点坐标进行微调,避免因坐标一致导致弧无法绘制
if (d.target === d.source) {
const dr = 30 / d.linknum
return 'M' + d.source.x + ',' + d.source.y + 'A' + dr + ',' + dr + ' 0 1,1 ' + d.target.x + ',' + (d.target.y + 1)
} else if (d.size % 2 !== 0 && d.linknum === 1) {
// 如果两个节点之间的连接线数量为奇数条,则设置编号为1的连接线为直线,其他连接线会均分在两边
const tan = Math.abs((d.target.y - d.source.y) / (d.target.x - d.source.x))
const x1 = d.target.x - d.source.x > 0 ? Math.sqrt(24 * 24 / (tan * tan + 1)) + d.source.x : d.source.x - Math.sqrt(24 * 24 / (tan * tan + 1))
const y1 = d.target.y - d.source.y > 0 ? Math.sqrt(24 * 24 * tan * tan / (tan * tan + 1)) + d.source.y : d.source.y - Math.sqrt(24 * 24 * tan * tan / (tan * tan + 1))
const x2 = d.target.x - d.source.x > 0 ? d.target.x - Math.sqrt(24 * 24 / (1 + tan * tan)) : d.target.x + Math.sqrt(24 * 24 / (1 + tan * tan))
const y2 = d.target.y - d.source.y > 0 ? d.target.y - Math.sqrt(24 * 24 * tan * tan / (1 + tan * tan)) : d.target.y + Math.sqrt(24 * 24 * tan * tan / (1 + tan * tan))
return 'M' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2
}
// 根据连接线编号值来动态确定该条椭圆弧线的长半轴和短半轴,当两者一致时绘制的是圆弧
// 注意A属性后面的参数,前两个为长半轴和短半轴,第三个默认为0,第四个表示弧度大于180度则为1,小于则为0,这在绘制连接到相同节点的连接线时用到;第五个参数,0表示正角,1表示负角,即用来控制弧形凹凸的方向。本文正是结合编号的正负情况来控制该条连接线的凹凸方向,从而达到连接线对称的效果
const curve = 3
const homogeneous = 1.2
const dx = d.target.x - d.source.x
const dy = d.target.y - d.source.y
let dr = Math.sqrt(dx * dx + dy * dy) * (d.linknum + homogeneous) / (curve * homogeneous)
// 圆心连线tan值
const tan = Math.abs(dy / dx)
const x1 = dx > 0 ? Math.sqrt(24 * 24 / (tan * tan + 1)) + d.source.x : d.source.x - Math.sqrt(24 * 24 / (tan * tan + 1))
const y1 = dy > 0 ? Math.sqrt(24 * 24 * tan * tan / (tan * tan + 1)) + d.source.y : d.source.y - Math.sqrt(24 * 24 * tan * tan / (tan * tan + 1))
const x2 = dx > 0 ? d.target.x - Math.sqrt(24 * 24 / (1 + tan * tan)) : d.target.x + Math.sqrt(24 * 24 / (1 + tan * tan))
const y2 = dy > 0 ? d.target.y - Math.sqrt(24 * 24 * tan * tan / (1 + tan * tan)) : d.target.y + Math.sqrt(24 * 24 * tan * tan / (1 + tan * tan))
// 当节点编号为负数时,对弧形进行反向凹凸,达到对称效果
if (d.linknum < 0) {
dr = Math.sqrt(dx * dx + dy * dy) * (-1 * d.linknum + homogeneous) / (curve * homogeneous)
return 'M' + x1 + ',' + y1 + 'A' + dr + ',' + dr + ' 0 0,0 ' + x2 + ',' + y2
}
return 'M' + x1 + ',' + y1 + 'A' + dr + ',' + dr + ' 0 0,1 ' + x2 + ',' + y2
})
node.attr('transform', (d) => ('translate(' + d.x + ',' + d.y + ')'))
// 连线文字角度
linetext.attr('transform', (d) => {
if (d.target.x < d.source.x) {
const { x, y, width, height } = document.getElementById('linkText-' + d.entire.id + '-source-' + d.entire.source + '-target-' + d.entire.target).getBBox()
const rx = x + width / 2
const ry = y + height / 2
return 'rotate(180 ' + rx + ' ' + ry + ')'
} else {
return 'rotate(0)'
}
})
}
// 向前推动力导向,确定节点位置坐标
function advance(force, num) {
for (let i = 0, n = num; i < n; ++i) {
force.tick()
}
}
function bindNodeClick(node) {
// 单击
node.on('click', (d) => {
console.log('单击')
})
// 双击
node.on('dblclick', (d) => {
console.log('双击')
})
// 右击
node.on('contextmenu', (d) => {
console.log('右击')
})
}
3. dataCollation.js
// 整理连接线关系
function collationLinksData(links, linkmap, lGroup) {
const arr = []
for (const i in links) {
arr.push({
source: links[i].source,
target: links[i].target,
id: links[i].id,
entire: links[i]
})
}
// 对连接线进行统计和分组,不区分连接线的方向,只要属于同两个实体,即认为是同一组
for (var i = 0; i < arr.length; i++) {
const key = arr[i].source < arr[i].target ? arr[i].source + '-' + arr[i].target : arr[i].target + '-' + arr[i].source
if (!linkmap.hasOwnProperty(key)) {
linkmap[key] = 0
}
linkmap[key] += 1
if (!lGroup.hasOwnProperty(key)) {
lGroup[key] = []
}
lGroup[key].push(arr[i])
}
// 为每一条连接线分配size属性,同时对每一组连接线进行编号
for (var i = 0; i < arr.length; i++) {
const key = arr[i].source < arr[i].target ? arr[i].source + '-' + arr[i].target : arr[i].target + '-' + arr[i].source
arr[i].size = linkmap[key]
// 同一组的关系进行编号
const group = lGroup[key]
const keyPair = key.split('-')
// 标示该组关系是指向两个不同实体还是同一个实体
let type = 'noself'
if (keyPair[0] === keyPair[1]) {
type = 'self'
}
// 给节点分配编号
setLinkNumber(group, type)
}
return arr
}
// 给节点分配编号 linknum
function setLinkNumber(group, type) {
if (group.length === 0) return
// 对该分组内的关系按照方向进行分类,此处根据连接的实体ASCII值大小分成两部分
const linksA = []
const linksB = []
for (let i = 0; i < group.length; i++) {
const link = group[i]
if (link.source < link.target) {
linksA.push(link)
} else {
linksB.push(link)
}
}
// 确定关系最大编号。为了使得连接两个实体的关系曲线呈现对称,根据关系数量奇偶性进行平分。
// 特殊情况:当关系都是连接到同一个实体时,不平分
let maxLinkNumber = 0
if (type === 'self') {
maxLinkNumber = group.length
} else {
maxLinkNumber = group.length % 2 === 0 ? group.length / 2 : (group.length + 1) / 2
}
// 如果两个方向的关系数量一样多,直接分别设置编号即可
if (linksA.length === linksB.length) {
let startLinkNumber = 1
for (let i = 0; i < linksA.length; i++) {
linksA[i].linknum = startLinkNumber++
}
startLinkNumber = 1
for (let i = 0; i < linksB.length; i++) {
linksB[i].linknum = startLinkNumber++
}
} else {
// 当两个方向的关系数量不对等时,先对数量少的那组关系从最大编号值进行逆序编号,然后在对另一组数量多的关系从编号1一直编号到最大编号,再对剩余关系进行负编号
// 如果抛开负号,可以发现,最终所有关系的编号序列一定是对称的(对称是为了保证后续绘图时曲线的弯曲程度也是对称的)
var biggerLinks, smallerLinks
if (linksA.length > linksB.length) {
biggerLinks = linksA
smallerLinks = linksB
} else {
biggerLinks = linksB
smallerLinks = linksA
}
let startLinkNumber = maxLinkNumber
for (let i = 0; i < smallerLinks.length; i++) {
smallerLinks[i].linknum = startLinkNumber--
}
const tmpNumber = startLinkNumber
startLinkNumber = 1
let p = 0
while (startLinkNumber <= maxLinkNumber) {
biggerLinks[p++].linknum = startLinkNumber++
}
// 开始负编号
startLinkNumber = 0 - tmpNumber
for (let i = p; i < biggerLinks.length; i++) {
biggerLinks[i].linknum = startLinkNumber++
}
}
}
效果图
转载请注明作者和出处,并添加本页链接。
原文链接:
//v2ci.com/21.html