force-directed-graph-component
Version:
force directed graph component
366 lines (331 loc) • 10.7 kB
JavaScript
import * as d3 from 'd3'
import {transformColors} from './util/colors'
// 获取d3提供的颜色目录 https://d3js.org/d3-scale-chromatic/categorical
const defaultColors = d3.scaleOrdinal(d3.schemeSet2);
let colors = {
...transformColors(defaultColors, 'group'),
}
// 默认样式
const defaultStyle = {
nodeTextSize: 12, // 节点文字大小
nodeTextWeight: 'bold', // 节点文字加粗情况
nodeTextColor: 'blue', // 节点文字颜色
nodeTextDx: 0, // 节点文字x轴偏移量
nodeTextDy: 3, // 节点文字y轴偏移量
linkTextSize: 12, // 路径文字大小
linkTextColor: 'green', // 路径文字颜色
linkColor: '#999', // 路径颜色
linkWidth: 2, // 路径宽度
linkOpacity: 0.6, // 路径透明度
linkTextDx: 0, // 路径文字x轴偏移量
linkTextDy: 0, // 路径文字y轴偏移量
linkTextWeight: 'bold', // 路径文字加粗情况
markerColor: '#666', // marker颜色
markerWidth: 2, // marker宽度
markerHeight: 10, // marker高度
positiveMarkerPath: 'M0 -5 L 10 0 L 0 5', //positiveMarker路径
negativeMarkerPath: 'M10 -5 L 0 0 L 10 5' //negativeMarker路径
}
// 默认配置项
const defaultConfig = {
showNodeText: true,
showLineText: true,
dragable: true,
fixWhenDragEnd: false,
collide: 60,
}
class ForceGraph {
constructor(el, data, config) {
// 力向图对象
this.graph = null
// 渲染容器
this.el = el
// 渲染数据
this.data = data
// 节点是否显示文字
this.showNodeText = Object.hasOwn(config, 'showNodeText') ? config.showNodeText : defaultConfig.showNodeText
// 连接线是否显示文字
this.showLineText = Object.hasOwn(config, 'showLineText') ? config.showLineText : defaultConfig.showLineText
// 是否可拖拽
this.dragable = Object.hasOwn(config, 'dragable') ? config.dragable : defaultConfig.dragable
// 拖拽结束节点位置是否暂时固定
this.fixWhenDragEnd = Object.hasOwn(config, 'fixWhenDragEnd') ? config.fixWhenDragEnd : defaultConfig.fixWhenDragEnd
// 节点间互斥半径
this.collide = Object.hasOwn(config, 'collide') ? config.collide : defaultConfig.collide
// 最终的配置项
this.finalConfig = {
...defaultConfig,
...config,
}
// 可取色
if (config.colors && config.colors.property) {
colors = {
...colors,
...transformColors(config.colors, config.colors.property)
}
}
this.init()
}
init() {
this.graph = new Graph(this.el, this.data, this.finalConfig)
}
// 获取节点实例
getNodes() {
return this.graph.nodes
}
// 获取路径实例
getLinks() {
return this.graph.links
}
// 获取画布
getSvg() {
return this.graph.svg
}
// resize() {
// const el = document.querySelector(this.el)
// const w = el.clientWidth
// const h = el.clientHeight
// console.log(w, h)
// this.graph.svg.attr("viewBox", [-w / 2, -h / 2, w, h])
// }
}
class Graph {
constructor(el, data, config) {
this.svg = null
this.g = null
this.simulation = null
this.nodeList = []
this.linkList = []
this.nodes = null
this.links = null
this.nodesText = null
this.linksText = null
this.markers = null
this.config = config
// 配置样式
this.styleObj = {...defaultStyle, ...config.styleObj}
this.initForceGraph(el, data, config)
this.renderPath()
this.renderNode()
this.renderMarker()
this.onCall()
}
initForceGraph(el, forceData, config) {
this.el = document.querySelector(el)
const w = this.el.clientWidth
const h = this.el.clientHeight
this.svg = d3
.select(el)
.append('svg')
.attr('width', w)
.attr('height', h)
.attr("viewBox", [-w / 2, -h / 2, w, h])
.call(
d3.zoom().on('zoom', () => {
this.g.attr('transform', d3.zoomTransform(this.g.node()))
})
)
this.linkList = forceData.links.map(d => ({...d}));
this.nodeList = forceData.nodes.map(d => ({...d}));
this.g = this.svg.append('g')
this.simulation = d3.forceSimulation(this.nodeList)
.force('link', d3.forceLink(this.linkList).id(d => d.id))
.force('collide', d3.forceCollide().radius(() => config.collide || 50))
.force('charge', d3.forceManyBody())
.force('x', d3.forceX())
.force('y', d3.forceY())
}
renderPath() {
// 路径
this.links = this.g.append('g')
.selectAll('path')
.data(this.linkList)
.enter()
.append('path')
.attr('stroke', (d) => {
return d.style && d.style.color ? d.style.color : this.styleObj.linkColor
})
.attr('stroke-opacity', this.styleObj.linkOpacity)
.attr('id', function(d, i) {
return 'edgepath' + i
})
.attr('stroke-width', (d) => {
return d.style && d.style.width ? d.style.width : this.styleObj.linkWidth
})
.attr('marker-end', 'url(#positiveMarker)')
// 路径文字
this.linksText = this.g.append('g')
.selectAll('text')
.data(this.linkList)
.enter()
.append('text')
.attr('dx', this.styleObj.linkTextDx)
.attr('dy', this.styleObj.linkTextDy)
.append("textPath")
.attr('startOffset', '50%')
.attr('text-anchor', 'middle')
.attr('xlink:href', function(d, i) {
return '#edgepath' + i
})
.text(function(d) {
return d.relationship
})
.attr('font-size', (d) => {
return d.linkText && d.linkText.fontSize || this.styleObj.linkTextSize
})
.attr('font-weight', this.styleObj.linkTextWeight)
.attr('fill', (d) => {
return d.linkText && d.linkText.color || this.styleObj.linkTextColor
})
}
renderNode() {
// 节点,根据分组定义形状
this.nodes = this.g.append('g')
.selectAll('rect')
.data(this.nodeList, d => d.id)
.join('rect')
.attr('rx', d => {
return d.node.radius || d.node.radiusX || 2
})
.attr('ry', d => {
return d.node.radius || d.node.radiusY || 2
})
.attr('x', 0)
.attr('y', 0)
.attr('width', d => {
return d.node.width || 30
})
.attr('height', d => {
return d.node.height || 30
})
.attr('class', 'node-hover')
.attr('transform', d => {
return `translate(-${d.node.width / 2}, -${d.node.height / 2})`
})
.attr('fill', d => colors.content(d[colors.property]))
this.nodes.append('title').text(d => d.name)
// 节点文字
this.nodesText = this.g.append('g')
.selectAll('text')
.data(this.nodeList)
.join('text')
.attr('text-anchor', 'middle')
.attr('class', 'force-text')
.text(function(d) {
return d.name
})
.attr('dx', this.styleObj.nodeTextDx)
.attr('dy', this.styleObj.nodeTextDy)
.attr('font-size', (d) => {
return d.nodeText && d.nodeText.fontSize || this.styleObj.nodeTextSize || ''
})
.attr('font-weight', this.styleObj.nodeTextWeight)
.attr('fill', (d) => {
return d.nodeText && d.nodeText.color || this.styleObj.nodeTextColor || ''
})
}
// 箭头标识
renderMarker() {
this.g.append('marker')
.attr('id', 'positiveMarker')
.attr('orient', 'auto')
.attr('stroke-width', 2)
.attr('markerUnits', 'strokeWidth')
.attr('markerUnits', 'userSpaceOnUse')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 26)
.attr('refY', 0)
.attr('markerWidth', this.styleObj.markerWidth)
.attr('markerHeight', this.styleObj.markerHeight)
.append('path')
.attr('d', this.styleObj.positiveMarkerPath)
.attr('fill', this.styleObj.markerColor)
.attr('stroke-opacity', 0.6)
this.g.append('marker')
.attr('id', 'negativeMarker')
.attr('orient', 'auto')
.attr('stroke-width', 2)
.attr('markerUnits', 'strokeWidth')
.attr('markerUnits', 'userSpaceOnUse')
.attr('viewBox', '0 -5 10 10')
.attr('refX', -12)
.attr('refY', 0)
.attr('markerWidth', this.styleObj.markerWidth)
.attr('markerHeight', this.styleObj.markerHeight)
.append('path')
.attr('d', this.styleObj.negativeMarkerPath)
.attr('fill', this.styleObj.markerColor)
.attr('stroke-opacity', 0.6)
}
// 添加额外事件
onCall() {
// 添加拖拽功能
if (this.config.dragable) {
this.nodes.call(d3.drag()
.on('start', d => {
this.dragStarted(d)
})
.on("drag", d => {
this.dragged(d)
})
.on("end", d => {
this.dragended(d)
}))
}
// 拖拽时节点和连接线的位置
this.simulation.on("tick", () => {
this.links
.attr('d', function(d) {
if (d.source.x < d.target.x) {
return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y
} else {
return 'M ' + d.target.x + ' ' + d.target.y + ' L ' + d.source.x + ' ' + d.source.y
}
})
.attr('marker-end', function(d) {
if (d.source.x < d.target.x) {
return 'url(#positiveMarker)'
} else {
return null
}
})
.attr('marker-start', function(d) {
if (d.source.x < d.target.x) {
return null
} else {
return 'url(#negativeMarker)'
}
})
this.nodes
.attr("x", d => d.x)
.attr("y", d => d.y)
this.nodesText
.attr("x", d => d.x)
.attr("y", d => d.y)
});
}
dragStarted(event) {
if (!event.active) this.simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
dragended(event) {
if (!event.active) this.simulation.alphaTarget(0);
console.log(this.config)
// 固定在当前鼠标放开位置
if (this.config.fixWhenDragEnd) {
event.subject.fx = event.x;
event.subject.fy = event.y;
} else {
// 回到原点
event.subject.fx = null;
event.subject.fy = null;
// this.simulation.stop()
}
}
}
export default ForceGraph