效果图:
代码:
<template>
<!--功能 1、d3力导向图-->
<!--坑 1、d3之v6版d3.event事件已取消,改为在回调中作为参数 2、attr只能分开写,不能合并写,合并写不能渲染出属性 3、图形/text颜色用fill、line颜色用stroke-->
<!--4、由于层级,先绘制线再节点 5、画布中的offsetX貌似是相对于svg 6、连线用path的一个原因是textpath元素href指向指定的path-->
<!--5、父元素穿透:pointer-events:none 6、设置svg中的样式:加/deep/或者去掉scope-->
<div class="force-page">
<div class="force-tip"
:style="{'top':`${tipData.point.y}px`,'left':`${tipData.point.x}px`,'display':isShowTip?'block':'none'}">
<ul>
<li @click="analysis()">分析1</li>
<li @click="analysis()">分析2</li>
</ul>
</div>
<!--静态svg-->
<!-- <svg>-->
<!-- <defs>-->
<!-- <g id="test1">-->
<!-- <circle cx="20" cy="20" r="20" fill="blue"></circle>-->
<!-- </g>-->
<!-- <g id="test2">-->
<!-- <circle cx="200" cy="0" r="20" fill="green"></circle>-->
<!-- </g>-->
<!-- </defs>-->
<!-- <g style="cursor: pointer">-->
<!-- <line x1="100" y1="100" x2="400" y2="300" stroke="#000"></line>-->
<!-- <line x1="100" y1="100" x2="400" y2="300" stroke="transparent" stroke-width="10"></line>-->
<!-- <rect x="240" y="190" width="20" height="20" fill="#ccc" stroke="blue" rx="6" ry="6"></rect>-->
<!-- <text x="250" y="208" font-size="20" text-anchor="middle" fill="#42a9f4">?</text>-->
<!-- <circle cx="100" cy="100" r="30" fill="#ccc" stroke="blue" stroke-width="2"></circle>-->
<!-- <text x="100" y="108" font-size="20" text-anchor="middle">河北</text>-->
<!-- <circle cx="400" cy="300" r="30" fill="#ccc" stroke="blue" stroke-width="2"></circle>-->
<!-- <text x="400" y="308" font-size="20" text-anchor="middle">北京</text>-->
<!-- </g>-->
<!-- <use x="0" y="0" xlink:href="#test1"></use>-->
<!-- <use x="100" y="100" xlink:href="#test2"></use>-->
<!--箭头-->
<!-- <line x1="50" y1="50" x2="100" y2="100" stroke="#000" marker-end="url(#arrow)"></line>-->
<!-- <defs>-->
<!-- <marker markerWidth="20" markerHeight="20" refX="10" refY="10" id="arrow" orient="auto">-->
<!-- <path d="M0 0 L10 10 L0 20 z" fill="none" stroke="blue"></path>-->
<!-- </marker>-->
<!-- </defs>-->
<!-- </svg>-->
</div>
</template>
<script>
import forceFun from '../../api/forceFun'
import * as d3 from 'd3'
export default {
name: "force",
data() {
return {
svgSrc: require('../../assets/images/gl.svg'),
tipData: {
point: {x: 100, y: 100},
data: null
},
isShowTip: false,
}
},
mounted() {
// this.drawForce()
this.drawForce1()
},
methods: {
drawForce() {
//数据
let nodesData = [{name: "桂林"}, {name: "广州"},
{name: "厦门"}, {name: "杭州"},
{name: "上海"}, {name: "青岛"},
{name: "天津"}];
let linksData = [{source: 0, target: 1}, {source: 0, target: 2},
{source: 0, target: 3}, {source: 1, target: 4},
{source: 1, target: 5}, {source: 1, target: 6}];
//开始布局画图
const width = 800;
const height = 600;
//初始化力学仿真器
let simulation = d3.forceSimulation(nodesData) //使用指定的nodes创建一个新的没有任何力模型的仿真
.force('link', d3.forceLink(linksData)) //弹簧力,为仿真添加指定name的力模型并返回仿真
.force('charge', d3.forceManyBody().strength(-1000)) //电荷力/万有引力/多体力
.force('center', d3.forceCenter(width / 2, height / 2)) //向心力
.on('tick', ticked)
//定义一个序数颜色比例尺
var color = d3.scaleOrdinal(d3.schemeCategory10)
//添加svg标签
// 1、attr只能分开写,不能合并写,合并写不能渲染出属性
let svg = d3.select('.force-page')
.append('svg')
.attr('id', 'chart')
.attr('width', width)
.attr('height', height)
//添加group
let gWapper = svg.append('g')
.attr('class', 'gWapper')
.attr('cursor', 'pointer')
//绘制连线
let links = gWapper.append('g') //root
.selectAll('line') //dom
.data(linksData) //model
.enter()
.append('line')
.attr("stroke", "yellow")
.attr("stroke-width", 1);
//绘制节点
let nodes = gWapper.append('g')
.selectAll('circle')
.data(nodesData)
.enter()
.append('circle')
.attr('r', 20)
.attr('fill', (d, i) => color(i))
.call(d3.drag()
.on('start', dragstart)
.on('drag', dragged)
.on('end', dragend)
)
//绘制文字
let texts = gWapper.append('g')
.selectAll('text')
.data(nodesData)
.enter()
.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '0.3em')
.text(d => d.name)
.call(d3.drag()
.on('start', dragstart)
.on('drag', dragged)
.on('end', dragend)
)
function dragstart(event, d) {
if (!event.active) {
simulation.alphaTarget(.2).restart()
}
d.fx = d.x
d.fy = d.y
}
function dragged(event, d) {
d.fx = event.x
d.fy = event.y
}
function dragend(event, d) {
if (!event.active) {
simulation.alphaTarget(0)
}
d.fx = null
d.fy = null
}
//虽然仿真系统会更新节点的位置(只是设置了nodes对象的x y属性),但是它不会转为svg内部元素的坐标表示,这需要我们自己来操作
function ticked() {
links.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
nodes.attr('cx', d => d.x)
.attr('cy', d => d.y)
texts.attr('x', d => d.x)
.attr('y', d => d.y)
// .attr('dominant-baseline', 'middle')
}
},
drawForce1() {
let width = 800, height = 600;
const opacityRange = [0.7, 1.0];
const nodeSizeRange = [10, 50];
const linkParallelGap = 6;
let opacityScale, nodeSizeScale;
let data = {
"nodes": [
{
"ntId": 1,
"label": "小明",
"number": 20
},
{
"ntId": 6,
"label": "小红",
"number": 17
},
{
"ntId": 7,
"label": "小刚",
"number": 140
},
{
"ntId": 8,
"label": "小乐",
"number": 43
}
],
"links": [
{
"sourceId": 7,
"targetId": 6,
"number": 2352
},
{
"sourceId": 7,
"targetId": 8,
"number": 1866
},
{
"sourceId": 6,
"targetId": 7,
"number": 1863
},
{
"sourceId": 7,
"targetId": 1,
"number": 788
},
{
"sourceId": 1,
"targetId": 7,
"number": 787
},
{
"sourceId": 8,
"targetId": 6,
"number": 676
},
{
"sourceId": 6,
"targetId": 8,
"number": 390
}
]
};
let {nodes, links} = data;
let linksData = []
let nodesData = nodes
linksData = links.map(d => {
return {
source: d.sourceId,
target: d.targetId,
attackCount: d.number,
};
})
generateScale()
/**
* 创建力导向图
*
*/
let simulation = d3.forceSimulation(nodesData)
//仿真中运行linksData后,linksData改变为与nodes绑定的数据格式
.force('link', d3.forceLink(linksData).id(d => d.ntId))
.force('charge', d3.forceManyBody().strength(-8000))
.force('center', d3.forceCenter(width / 2, height / 2))
.on('tick', ticked)
//创建svg
let svg = d3.select('.force-page')
.append('svg')
.attr('width', width)
.attr('height', height)
.style('background', '#ccc')
.on('click', () => {
this.isShowTip = false;
})
let gWrapper = svg.append('g')
//连线
let linkEles = gWrapper.append('g')
.classed('links', true)
.selectAll('path')
.data(linksData)
.enter()
//单个线
// .append('line')
//双向线
.append('path')
.attr('id', (d, i) => `linkPath${i}`)
.classed('link', true)
.attr('stroke', 'yellow')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrow)')
.attr('opacity', d => opacityScale(+d.number))
.style('cursor', 'pointer')
.on('mouseenter', (event, d) => {
linkLabelEles.attr('opacity', item => {
return item === d ? 1 : 0;
})
linkEles.attr('opacity', item => {
return item === d ? 1 : 0.3;
})
})
.on('mouseleave', () => {
linkLabelEles.attr('opacity', 0)
linkEles.attr('opacity', 1)
})
//节点
let nodeEles = gWrapper.append('g')
.attr('class', 'nodes')
.style('cursor', 'pointer')
.selectAll('.node')
.data(nodesData)
.enter()
.append('g')
.classed('node', true)
.attr('opacity', d => {
// 修复特定条件下,透明度为负值bug
let tempOpacity = opacityScale(+d.number);
return tempOpacity ? tempOpacity : 1;
})
.call(d3.drag()
.on('start', dragstart)
.on('drag', dragged)
.on('end', dragend)
)
.on('click', (event, d) => {
event.stopPropagation();
this.isShowTip = true;
this.tipData = {
point: {x: event.offsetX, y: event.offsetY},
data: d
}
})
.on('mouseenter', (event, d) => {
linkEles.attr('opacity', item => {
return (item.target.ntId === d.ntId || item.source.ntId === d.ntId) ? 1 : 0
})
nodeLabelElms.text(item => d.ntId === item.ntId ? item.label : ellipse(item.label, 8))
})
.on('mouseleave', (event, d) => {
linkEles.attr('opacity', 1)
nodeLabelElms.text(item => ellipse(item.label, 8))
})
//图片
nodeEles.append('image')
//.attr('xlink:href', this.svgSrc)
.attr('href', this.svgSrc)
.attr('class', 'node-icon')
.attr('width', d => nodeSizeScale(+d.number))
.attr('height', d => nodeSizeScale(+d.number))
.attr('x', d => -nodeSizeScale(+d.number) / 2)
.attr('y', d => -nodeSizeScale(+d.number) / 2)
let nodeLabelContainer = nodeEles.append('g')
.classed('node-label', true)
.attr('transform', d => {
const y = nodeSizeScale(+d.number) * 0.35;
return `translate(0,${y})`
})
//文字
let nodeLabelElms = nodeLabelContainer.append('text')
.attr('dy', '1em')
.attr('fill', '#fff')
.text(d => ellipse(d.label, 8))
//重点:文字背景矩形
let textPadding = 6;
nodeLabelContainer.each(function (d) {
//d:当前元素绑定的绑定的数据,this:函数内部 this 指向当前 DOM 元素(node[i])
let textBox = this.getBBox()
//或者
// let textBox = d3.select(this)
// .select('text')
// .node()
// .getBBox()
d3.select(this)
.insert('rect', 'text')
.attr('x', textBox.x - textPadding)
.attr('y', textBox.y - textPadding)
.attr('width', textBox.width + textPadding * 2)
.attr('height', textBox.height + textPadding * 2)
.attr('fill', '#ea3d66')
})
//重点:箭头
let arrow = gWrapper.append('defs')
.append('marker')
.attr('id', 'arrow')
.attr('markerWidth', 20)
.attr('markerHeight', 20)
.attr('refX', 4)
.attr('refY', 4)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0 0 L4 4 L0 8Z')
.attr('fill', 'none')
.attr('stroke', 'blue')
function ticked() {
linkEles.classed('animate', false)
// 当只有单向线时
// linkEles.attr('x1', d => d.source.x)
// .attr('y1', d => d.source.y)
// .attr('x2', d => d.target.x)
// .attr('y2', d => d.target.y)
nodeEles.attr('transform', d => {
return `translate(${d.x},${d.y})`
})
//双向线
linkEles.attr('d', d => {
if (d.target && d.source) {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = 0;
const slopeVec = {x: dx, y: dy};
let transformedSource = addVector(d.source, slopeVec, 15);
let transformedTarget = addVector(
d.target,
{x: dx, y: dy},
-1 * 15,
);
transformedSource = parallelTransform(
transformedSource,
slopeVec,
linkParallelGap,
);
transformedTarget = parallelTransform(
transformedTarget,
slopeVec,
linkParallelGap,
);
//弧线
//A圆弧:M 起始点x 起始点y A 椭圆x 椭圆y 椭圆旋转角度 大弧(1)还是小弧(0) 顺时针(1)还是逆时针(0) 终点x 终点y
// return (
// 'M' +
// transformedSource.x +
// ',' +
// transformedSource.y +
// 'A' +
// dr +
// ',' +
// dr +
// ' 0 0,1 ' +
// transformedTarget.x +
// ',' +
// transformedTarget.y
// );
//直线
return (
'M' +
transformedSource.x +
',' +
transformedSource.y +
'L ' +
transformedTarget.x +
',' +
transformedTarget.y
);
}
})
//攻击文字旋转
linkLabelEles.attr('transform', function (d) {
if (d.source && d.target) {
if (d.source.x > d.target.x) {
const bBox = this.getBBox();
let rx = bBox.x + bBox.width / 2
let ry = bBox.y + bBox.height / 2
return `rotate(180,${rx},${ry})`
} else {
return `rotate(0)`
}
}
})
if (simulation.alpha() < 0.02) {
linkEles.classed('animate', true)
simulation.stop();
}
}
//创建攻击文字
let linkLabelEles = gWrapper.append('g')
.classed('link-labels', true)
.selectAll('text')
.data(linksData)
.enter()
.append('text')
.attr('dy', -10)
.attr('text-anchor', 'middle')
.attr('opacity', 0)
.style('pointer-events', 'none') //让父元素可以穿透
// 文字路径
linkLabelEles.append('textPath')
.attr('href', (d, i) => `#linkPath${i}`)
.text(d => `攻击数:${d.attackCount}`)
.attr('startOffset', '50%')
.style('pointer-events', 'none')
function dragstart(event, d) {
if (!event.active) {
simulation.alphaTarget(0.3).restart()
}
d.fx = d.x
d.fy = d.y
}
function dragged(event, d) {
d.fx = event.x
d.fy = event.y
}
function dragend(event, d) {
if (!event.active) {
simulation.alphaTarget(0)
}
d.fx = null
d.fy = null
}
function ellipse(str, maxLength) {
let s = String(str);
return s.length > maxLength ? `${s.substring(0, maxLength)}...` : s;
}
/**
* 双向的添加1,2标识
* @param link
* @param i
*/
function checkBiLink(link, i) {
let sameIdx;
try {
sameIdx = linksData.findIndex(item => {
return (
item.source.ntId === link.target.ntId &&
item.target.ntId === link.source.ntId &&
!item.bidir
);
});
} catch {
sameIdx = -1;
}
if (sameIdx > i) {
link.bidir = 1;
linksData[sameIdx].bidir = 2;
}
}
linksData.forEach(checkBiLink);
/**
* 生成scale
*/
function generateScale() {
opacityScale = d3.scaleLinear()
.domain(d3.extent(linksData, d => +d.attackCount))
.range(opacityRange)
nodeSizeScale = d3.scaleLinear()
.domain(d3.extent(nodesData, d => +d.number))
.range(nodeSizeRange)
}
//k线弧度
function calcAngleDegrees(x, y) {
return Math.atan2(y, x);
}
//缩短矢量连接线,过程详解见笔记中svg常见绘制效果
function addVector(point, slopeVec, unit) {
const AngleDegrees = calcAngleDegrees(slopeVec.x, slopeVec.y);
let x1 = point.x + Math.cos(AngleDegrees) * unit;
let y1 = point.y + Math.sin(AngleDegrees) * unit;
return {x: x1, y: y1};
}
//绘制平行线
function parallelTransform(point, slopeVec, unit) {
const AngleDegrees = calcAngleDegrees(slopeVec.x, slopeVec.y) + Math.PI/2;
let x1 = point.x + Math.cos(AngleDegrees) * unit;
let y1 = point.y + Math.sin(AngleDegrees) * unit;
return {x: x1, y: y1};
}
},
analysis() {
console.log(this.tipData.data);
},
}
}
</script>
<style lang="less" scoped>
/*stroke-dashoffset的from和to相加要为一个虚线的单位宽度加空隙,才不会有明显的过度效果*/
@keyframes linkMove {
from {
stroke-dashoffset: 0;
stroke-dasharray: 10;
}
to {
stroke-dashoffset: 20;
stroke-dasharray: 10;
}
}
.force-page {
width: 100%;
height: 100%;
position: relative;
.force-tip {
position: absolute;
}
/deep/ svg {
.link {
&.animate {
animation: linkMove 1s infinite linear;
}
}
}
}
</style>