基于Vue与Cytoscape实现企业控股关系图
1.需求绘制如爱企查所示的关系图,主要展示控股关系,图形按照控股链路层次递进,链路最长的企业放置在最下面;要求节点可拖动,拖动时连线和线上文本跟随拖动;鼠标悬浮显示节点相关信息;每条链路开始的节点顶部对齐;每条链路开始的节点顶部展示一个描述信息,该节点被拖动时,提示跟随拖动;点击每个节点,高亮展示从该节点开始往后的链路及节点,其余无关联节点和上层节点与连线透明度均降低;节点和节点之间可能出现环;数
·
1.需求
- 绘制如爱企查所示的关系图,主要展示控股关系,图形按照控股链路层次递进,链路最长的企业放置在最下面;
- 要求节点可拖动,拖动时连线和线上文本跟随拖动;
- 鼠标悬浮显示节点相关信息;
- 每条链路开始的节点顶部对齐;
- 每条链路开始的节点顶部展示一个描述信息,该节点被拖动时,提示跟随拖动;
- 点击每个节点,高亮展示从该节点开始往后的链路及节点,其余无关联节点和上层节点与连线透明度均降低;
- 节点和节点之间可能出现环;
- 数据是动态加载的;具体交互可以参考爱企查:https://aiqicha.baidu.com/relations/finalbenefit?pid=49113123525375
- 综上,需要寻找支持类似树或流程图结构、节点样式可自定义,交互性强的绘图框架;
2.调研
根据需求分析,主要需要实现一个类似流程图的结构。在网上查阅资料进行调研,主要就Echarts,d3,GoJs,Cytoscape进行了深入研究。
2.1 Echarts
- 主要使用Echarts的
graph
绘图; - 出场动画挺好看;
- 节点坐标不能自动生成,需要手动设置每个节点的位置,不适用动态数据场景;
- 节点拖拽无法操作;
- 节点难以根据数据进行定制化;
- 官方demo:https://echarts.apache.org/examples/zh/editor.html?c=graph-simple
2.2 GoJs
- 绘制结果为Canvas,非常强大,可以绘制各类关系图,官方提供了许多接口,节点定制化强,基本满足需求;
- 但是不开源,调研了一周白搭……🤦♀️
- 官方网站:https://gojs.net/latest/samples/pageFlow.html
2.3 d3
- d3最大的优点就是所有元素都是svg,操作性较强,但正是因为这种超强的可操作性,对于不能使用d3库绘制的图,要添加额外操作会编写较多代码;
- d3中有dagre-d3插件可以绘制流程图,效果如下,整体结构较为符合,节点也可定制化,看上去也蛮漂亮的;dagre-d3在绘图时,会有一个类似防碰撞的算法,即连线尽量不与节点和其他连线相交,所以图的结构看上去会比较清晰;具体算法可参考:https://developer.aliyun.com/article/780079
- but……dagre-d3本身不支持节点拖拽,自己编写节点拖拽,还需要在拖拽时修改连线的起始位置,包括顶部添加说明,许多交互操作都需要自己手写(可能是我没找到合适的方法),想到所有交互操作都要自己去写,就令人头秃~🙅♀️
2.4 Cytoscape.js
- 根据官方文档描述,Cytoscape.js是一个纯用JS编写的全功能图形库,兼容所有现代浏览器, 绘图以canvas形式呈现;
- Cytoscape支持集合论运算,这对需要在图形上做数据过滤非常友好;
- 支持函数式编程模式,对节点的样式操作性较强,能支持绝大部分定制化;
- 支持节点和整个绘图对象的拖拽、点击、鼠标悬浮等事件,对节点的拖拽,其相关的连线会自动跟随;可以实现几乎所有需要的交互;
- 满足需求的图结构为
dagre
布局,效果如图,官方示例:https://cytoscape.org/cytoscape.js-dagre/ - 最重要的是,是可用于商业项目和生产中的开源项目;
3.问题解决
根据需求分析,结合已调研的框架,最终选定Cytoscape.js作为绘图框架进行实现,以下记录实现步骤。
3.1 环境
Vue 2.5.2 + Cytoscape 3.20.0,
3.2 安装
vue工程目录下安装Cytoscape。因为需要使用到dagre布局,所以还需要安装Cytoscape-dagre。
npm install cytoscape --save
npm install cytoscape-dagre --save
3.3 绘图
- 首先定义一个html元素,用作绘图容器
<template>
<div id="box">
<!-- 绘图容器 -->
<div id="cy"></div>
</div>
</template>
- 定义数据和样式,其中节点元素和连线的样式定义可参考官方文档:https://js.cytoscape.org/#style
import cytoscape from "cytoscape";
import cydagre from "cytoscape-dagre";
import dagre from "dagre";
cydagre(cytoscape, dagre);
let cy;
export default {
data() {
return {
nodes: [
{
data: {
id: "1",
label: "孙飘扬",
class: "first-node",
type: "person",
},
},
{
data: {
id: "2",
label: "无锡宏大投资有限公司",
class: "normal-company",
},
},
{
data: {
id: "3",
label: "无锡海润医药科技有限公司",
class: "normal-company",
},
},
{
data: {
id: "4",
label: "西藏天承投资管理有限公司",
class: "normal-company",
},
},
{
data: {
id: "5",
label: "钟慧娟",
class: "first-node",
type: "company",
},
},
{
data: {
id: "6",
label: "江苏恒瑞医药集团有限公司",
class: "last-node",
},
},
],
edges: [
{ data: { source: "5", target: "4", label: "100%" } },
{ data: { source: "1", target: "2", label: "25.43%" } },
{ data: { source: "1", target: "3", label: "10%" } },
{ data: { source: "1", target: "6", label: "89.22%" } },
{ data: { source: "2", target: "6", label: "10.78%" } },
{ data: { source: "4", target: "3", label: "90%" } },
{ data: { source: "3", target: "2", label: "74.57%" } },
],
firstNodeBg: "#fd485d", // 第一个节点的背景颜色
lastNodeBg: "#128bed", // 最后一个节点的背景颜色
currentHoverData: {},
showTips: false,
tippyArr: [],
};
},
mounted() {
// 创建图
this.createCytoscape();
},
methods: {
createCytoscape() {
cy = cytoscape({
container: document.getElementById("cy"), //设置容器
boxSelectionEnabled: false,
autounselectify: true,
layout: {
name: "dagre",
},
zoom: 0.6,
minZoom: 0.3,
maxZoom: 2,
fit: true,
boxSelectionEnabled: false,
style: [
{
selector: "node",
style: {
content: "data(label)",
shape: "rectangle",
width: 120,
padding: 5,
color: "#000",
boxSelectionEnabled: false,
"overlay-opacity": 0, // 取消默认的元素选择时的阴影
"text-max-width": 120,
"text-wrap": "wrap",
"text-overflow-wrap": "anywhere",
"line-height": 1.5,
"text-valign": "center",
"text-halign": "center",
"background-color": "#fff",
"border-width": 0.5,
"border-color": this.lastNodeBg,
"font-size": 12,
"font-weight": "lighter",
},
},
{
selector: "edge",
style: {
width: 1,
label: "data(label)", // 连线上的文字设置为数据中的label属性
color: this.lastNodeBg,
"target-arrow-shape": "triangle",
"line-color": "#ccc",
"target-arrow-color": this.lastNodeBg,
"arrow-scale": 0.7,
"curve-style": "bezier",
"font-size": 12,
"font-weight": "lighter",
"text-background-color": "#fff",
"text-overflow-wrap": "anywhere",
"text-background-opacity": 1,
"overlay-opacity": 0,
},
},
// 针对第一个节点设置样式
{
selector: "node[class='first-node']",
style: {
shape: "ellipse",
color: "white",
width: 30,
height: 30,
padding: 8,
"text-opacity": 1,
"border-width": 0,
"background-color": this.firstNodeBg,
},
},
// 针对最后一个节点设置样式
{
selector: "node[class='last-node']",
style: {
color: "white",
"background-color": this.lastNodeBg,
},
},
],
elements: {
nodes: this.nodes,
edges: this.edges,
},
});
},
getColor(firstNodeType) {
return firstNodeType === "person" ? this.firstNodeBg : this.lastNodeBg;
},
};
- 为节点添加点击事件,并在点击节点后,高亮显示从该节点开始的节点与连线关系;其中会用到Cytoscape当中重要的方法
node.successors()
和ele.difference()
方法,用于获取由当前节点开始的节点与连线,并筛选非高亮的部分,具体可参考官方手册:https://js.cytoscape.org/#nodes.successors
cy.on("tap", (e) => {
// 点击节点以外的地方和最后一个节点,则恢复所有图的展示
if (e.target.data().class === "last-node") return;
if (e.target === cy || e.target.isEdge()) {
cy.elements().map((item, index, el) => {
el.style("opacity", 1);
});
this.setTipContainersOpacity(1);
} else {
this.clickNode(e);
}
});
// setTipContainersOpacity方法
setTipContainersOpacity(opacity) {
let tipsContainerList = document.getElementsByClassName(
"first-node-description-container"
);
for (let i = 0; i < tipsContainerList.length; i++) {
tipsContainerList[i].style.opacity = opacity;
}
}
// clickNode方法
clickNode(e) {
let node = e.target;
this.setTipContainersOpacity(0.2);
// 获取与点击节点相关的元素(包括节点和连线)
let relativeEle = cy.$(`#${node.id()}`).successors();
relativeEle.map((item, index, el) => {
el.style("opacity", "1");
});
let allEle = cy.elements();
// 获取除点击节点相关的元素(包括节点和连线)以外的节点和边,
// 会包括点击元素本身,将其透明度降低
let leftEle = allEle.difference(relativeEle);
leftEle.map((item, index, el) => {
el.style("opacity", 0.2);
});
// 当前点击的是第一个节点,则将第一个节点的提示描述显示出来
if (e.target.data().class == "first-node") {
let tipContainer = document.getElementById(
`tipsContainer${e.target.id()}`
);
tipContainer.style.opacity = 1;
}
// 将被点击的元素透明度置为1
node.style("opacity", 1);
}
- 接下来将所有的第一个节点水平排列在一行上,并为第一个节点添加描述的提示信息。其中添加描述信息需要用到tippy.js和cytoscape-popper插件,安装如下:
npm install cytoscape-popper --save
npm install tippy.js --save
注意:在vue中使用tippy的sticky属性,需要单独引入。tippy配置项手册可以参考官方文档:https://atomiks.github.io/tippyjs/v6/all-props/
具体代码如下:
// vue使用sticky需要单独引入!!!!
import tippy, { sticky } from "tippy.js";
cytoscape.use(popper);
arrangeFirstNodeAndAddTips() {
let firstNodePositionArr = [];
// 获取第一个节点的y坐标
cy.elements().map((item, index, el) => {
if (item.data().class === "first-node") {
firstNodePositionArr.push(cy.$(`#${item.id()}`).position("y"));
let tippy = this.makeTippy(ele, dataObj);
tippy.show();
}
});
// 获取最小的y坐标
let minY = Math.min(...firstNodePositionArr);
// 将所有第一个节点的y坐标与最小的y坐标对齐
cy.elements().map((item, index, el) => {
if (item.data().class === "first-node") {
cy.$(`#${item.id()}`).position("y", minY);
}
});
},
makeTippy(ele, dataObj) {
let ref = ele.popperRef();
let dummyDomEle = document.createElement("div");
let tip = new tippy(dummyDomEle, {
getReferenceClientRect: ref.getBoundingClientRect,
arrow: true,
allowHTML: true,
trigger: "manual", // mandatory
content: this.createDescriptionTip(dataObj),
placement: "top",
hideOnClick: false,
sticky: "reference", // 设置该值可以实现在节点拖动时,提示始终跟随节点
plugins: [sticky], // 必须添加
interactive: true,
interactive: true,
appendTo: document.body,
});
this.tippyArr.push(tip);
return tip;
},
至此,几乎已实现所有需求功能。本次调研最大的心得体会就是英语阅读能力真的很重要,许多强大的框架,其官方文档几乎都是全英文,还有就是学习多阅读官方文档。当找不到完整的中文学习文档时,还是努力看英文官方文档。一切以官方文档为准!!
最后附上完整代码:
<template>
<div id="box">
<div id="cy">
<div
id="tipsWrapper"
v-show="showTips"
@mouseover="hoverTips"
@mouseout="mouseoutTips"
:style="{ left: tipContainerX, top: tipContainerY }"
>
<h1>{{ currentHoverData.label }}</h1>
</div>
</div>
</div>
</template>
<script>
import cytoscape from "cytoscape";
import cydagre from "cytoscape-dagre";
import popper from "cytoscape-popper";
import dagre from "dagre";
// vue使用sticky需要单独引入!!!!
import tippy, { sticky } from "tippy.js";
cytoscape.use(popper);
cydagre(cytoscape, dagre);
let cy;
export default {
name: "cytoscape",
components: {},
data() {
return {
tipContainerX: 0,
tipContainerY: 0,
nodes: [
{
data: {
id: "1",
label: "孙飘扬",
class: "first-node",
type: "person",
},
},
{
data: {
id: "2",
label: "无锡宏大投资有限公司",
class: "normal-company",
},
},
{
data: {
id: "3",
label: "无锡海润医药科技有限公司",
class: "normal-company",
},
},
{
data: {
id: "4",
label: "西藏天承投资管理有限公司",
class: "normal-company",
},
},
{
data: {
id: "5",
label: "钟慧娟",
class: "first-node",
type: "company",
},
},
{
data: {
id: "6",
label: "江苏恒瑞医药集团有限公司",
class: "last-node",
},
},
],
edges: [
{ data: { source: "5", target: "4", label: "100%" } },
{ data: { source: "1", target: "2", label: "25.43%" } },
{ data: { source: "1", target: "3", label: "10%" } },
{ data: { source: "1", target: "6", label: "89.22%" } },
{ data: { source: "2", target: "6", label: "10.78%" } },
{ data: { source: "4", target: "3", label: "90%" } },
{ data: { source: "3", target: "2", label: "74.57%" } },
],
cy: {},
firstNodeBg: "#fd485d",
lastNodeBg: "#128bed",
currentHoverData: {},
showTips: false,
tippyArr: [],
};
},
mounted() {
// 创建图
this.createCytoscape();
// 将第一个节点水平排列在同一行上,并为第一个节点添加说明
this.arrangeFirstNodeAndAddTips();
this.addTapEventListener();
this.addMouseOverOutEvent();
this.addDragEventListenter();
this.addZoomEventListener();
},
beforeDestroy() {
this.tippyArr.map((item) => {
item.destroy();
});
},
methods: {
createCytoscape() {
cy = cytoscape({
container: document.getElementById("cy"), //设置容器
boxSelectionEnabled: false,
autounselectify: true,
layout: {
name: "dagre",
},
zoom: 0.6,
minZoom: 0.3,
maxZoom: 2,
fit: true,
boxSelectionEnabled: false,
style: [
{
selector: "node",
style: {
content: "data(label)",
shape: "rectangle",
width: 120,
padding: 5,
color: "#000",
boxSelectionEnabled: false,
"overlay-opacity": 0, // 取消默认的元素选择时的阴影
"text-max-width": 120,
"text-wrap": "wrap",
"text-overflow-wrap": "anywhere",
"line-height": 1.5,
"text-valign": "center",
"text-halign": "center",
"background-color": "#fff",
"border-width": 0.5,
"border-color": this.lastNodeBg,
"font-size": 12,
"font-weight": "lighter",
// "min-zoomed-font-size": 12,
},
},
{
selector: "edge",
style: {
width: 1,
label: "data(label)",
color: this.lastNodeBg,
"target-arrow-shape": "triangle",
"line-color": "#ccc",
"target-arrow-color": this.lastNodeBg,
"arrow-scale": 0.7,
"curve-style": "bezier",
"font-size": 12,
"font-weight": "lighter",
"text-background-color": "#fff",
"text-overflow-wrap": "anywhere",
"text-background-opacity": 1,
"overlay-opacity": 0,
},
},
{
selector: "node[class='first-node']",
style: {
shape: "ellipse",
color: "white",
width: 30,
height: 30,
padding: 8,
"text-opacity": 1,
"border-width": 0,
"background-color": this.firstNodeBg,
},
},
{
selector: "node[class='last-node']",
style: {
color: "white",
"background-color": this.lastNodeBg,
},
},
],
elements: {
nodes: this.nodes,
edges: this.edges,
},
});
},
getColor(firstNodeType) {
return firstNodeType === "person" ? this.firstNodeBg : this.lastNodeBg;
},
addTips(ele, dataObj) {
let tippyA = this.makeTippy(ele, dataObj);
tippyA.show();
},
arrangeFirstNodeAndAddTips() {
let firstNodePositionArr = [];
// 获取第一个节点的y坐标
cy.elements().map((item, index, el) => {
if (item.data().class === "first-node") {
firstNodePositionArr.push(cy.$(`#${item.id()}`).position("y"));
this.addTips(item, item.data());
}
});
// 获取最小的y坐标
let minY = Math.min(...firstNodePositionArr);
// 将所有第一个节点的y坐标与最小的y坐标对齐
cy.elements().map((item, index, el) => {
if (item.data().class === "first-node") {
cy.$(`#${item.id()}`).position("y", minY);
}
});
},
makeTippy(ele, dataObj) {
let ref = ele.popperRef();
let dummyDomEle = document.createElement("div");
let tip = new tippy(dummyDomEle, {
getReferenceClientRect: ref.getBoundingClientRect,
arrow: true,
allowHTML: true,
trigger: "manual", // mandatory
content: this.createDescriptionTip(dataObj),
placement: "top",
hideOnClick: false,
sticky: "reference",
plugins: [sticky], // 必须添加
interactive: true,
interactive: true,
appendTo: document.body,
});
this.tippyArr.push(tip);
return tip;
},
clickNode(e) {
let node = e.target;
this.setTipContainersOpacity(0.2);
// 获取与点击节点相关的元素(包括节点和连线)
let relativeEle = cy.$(`#${node.id()}`).successors();
relativeEle.map((item, index, el) => {
el.style("opacity", "1");
});
let allEle = cy.elements();
// 获取除点击节点相关的元素(包括节点和连线)以外的节点和边,
// 会包括点击元素本身,将其透明度降低
let leftEle = allEle.difference(relativeEle);
leftEle.map((item, index, el) => {
el.style("opacity", 0.2);
});
// 当前点击的是第一个节点,则将第一个节点的提示描述显示出来
if (e.target.data().class == "first-node") {
let tipContainer = document.getElementById(
`tipsContainer${e.target.id()}`
);
tipContainer.style.opacity = 1;
}
// 将被点击的元素透明度置为1
node.style("opacity", 1);
},
createDescriptionTip(dataObj) {
let color = this.getColor(dataObj.type);
// 绘制文本
let tipContainer = document.createElement("div");
tipContainer.classList.add("first-node-description-container");
tipContainer.innerHTML = dataObj.label;
tipContainer.style.background = color;
tipContainer.setAttribute("id", `tipsContainer${dataObj.id}`);
// 绘制提示小箭头
let arrowDiv = document.createElement("div");
arrowDiv.classList.add("first-node-description-arrow");
arrowDiv.style.borderColor = `${color} transparent transparent transparent`;
tipContainer.appendChild(arrowDiv);
return tipContainer;
},
addTapEventListener() {
cy.on("tap", (e) => {
// 点击节点以外的地方,则恢复所有图的展示
if (e.target.data().class === "last-node") return;
if (e.target === cy || e.target.isEdge()) {
cy.elements().map((item, index, el) => {
el.style("opacity", 1);
});
this.setTipContainersOpacity(1);
} else {
this.clickNode(e);
}
});
},
addMouseOverOutEvent() {
cy.on("mouseover", "node", (e) => {
this.showTips = true;
this.currentHoverData = e.target.data();
this.$nextTick(() => {
this.moveTipsContainer(e);
});
});
cy.on("mouseout", "node", (e) => {
this.showTips = false;
});
},
addDragEventListenter() {
cy.on("drag", "node", (e) => {
this.moveTipsContainer(e);
});
},
addZoomEventListener() {
cy.on("zoom", (e) => {});
},
// 设置提示的位置
moveTipsContainer(e) {
this.tipContainerX =
e.target.renderedPosition().x - e.target.width() / 2 + "px";
this.tipContainerY =
e.target.renderedPosition().y + e.target.height() - 10 + "px";
},
setTipContainersOpacity(opacity) {
let tipsContainerList = document.getElementsByClassName(
"first-node-description-container"
);
for (let i = 0; i < tipsContainerList.length; i++) {
tipsContainerList[i].style.opacity = opacity;
}
},
hoverTips() {
this.showTips = true;
},
mouseoutTips() {
this.showTips = false;
},
},
};
</script>
<style>
#box {
width: 100%;
height: 600px;
position: relative;
}
#cy {
width: 90%;
height: 100%;
position: absolute;
top: 100px;
z-index: 999;
}
#tipsWrapper {
box-shadow: 0px 0px 4px #ccc;
width: 300px;
padding: 10px 0px;
position: absolute;
background: #fff;
z-index: 3;
}
.__________cytoscape_container {
position: relative !important;
}
canvas {
left: 0 !important;
}
h1 {
font-size: 1em;
font-weight: normal;
}
/* makes sticky faster; disable if you want animated tippies */
.tippy-popper {
transition: none !important;
}
.first-node-description-container {
color: #fff;
padding: 5px 5px;
position: relative;
margin-bottom: 5px;
}
.first-node-description-arrow {
position: absolute;
border: 5px solid #fff;
top: 100%;
left: 50%;
transform: translateX(-50%);
}
</style>
4.补充
- 使用
popper+tippy
来实现的提示,发现在图进行缩放时,提示不会跟随缩放; - 于是又采取了一种新的思路。将提示也作为一个节点,把提示当成一个普通的节点,第一个节点与其提示作为一组关系,当提示的节点与第一个节点位置逼近时,连线几乎相当于没有,此时提示与第一个节点之间的关系就只存在箭头,该箭头能作为提示的三角进行显示;这样可以提示的缩放与节点的缩同步的;
- 但是当第一个节点拖动时,由于提示与第一个节点之间是连线关系,三角箭头会脱离提示,同时位置也会因为第一个节点的拖动而产生偏移;因此需要针对第一个节点的拖拽事件进行监听,实时更新提示的位置;
- 同时由于提示本身也是一个节点,节点本身是有拖拽事件,所以需要禁用提示的拖拽,此时会用到
ele.lock()
方法禁止用户操作该节点;当更新提示框位置时,又需要该节点位置是可以设置的,需要使用ele.unlock()
方法先解锁。更新后的完整代码如下:
<template>
<div id="box">
<div id="cy">
<div
id="tipsWrapper"
v-show="showTips"
@mouseover="hoverTips"
@mouseout="mouseoutTips"
:style="{ left: tipContainerX, top: tipContainerY }"
>
<h1>{{ currentHoverData.label }}</h1>
</div>
</div>
</div>
</template>
<script>
import cytoscape from "cytoscape";
import cydagre from "cytoscape-dagre";
import popper from "cytoscape-popper";
import dagre from "dagre";
// vue使用sticky需要单独引入!!!!
import tippy, { sticky } from "tippy.js";
cytoscape.use(popper);
cydagre(cytoscape, dagre);
let cy;
export default {
name: "cytoscape",
components: {},
data() {
return {
tipContainerX: 0,
tipContainerY: 0,
nodes: [
{
data: {
id: "a",
label: "实际控制人\n最终受益人\n最终收益股份92.76%",
class: "red",
type: "tips",
target: "1",
},
},
{
data: {
id: "b",
label: "实际控制人\n最终受益人\n最终收益股份92.76%",
class: "blue",
type: "tips",
target: "5",
},
},
{
data: {
id: "1",
label: "孙飘扬",
class: "first-node",
type: "person",
},
},
{
data: {
id: "2",
label: "无锡宏大投资有限公司",
class: "normal-company",
},
},
{
data: {
id: "3",
label: "无锡海润医药科技有限公司",
class: "normal-company",
},
},
{
data: {
id: "4",
label: "西藏天承投资管理有限公司",
class: "normal-company",
},
},
{
data: {
id: "5",
label: "钟慧娟",
class: "first-node",
type: "company",
},
},
{
data: {
id: "6",
label: "江苏恒瑞医药集团有限公司",
class: "last-node",
},
},
],
edges: [
{
data: {
source: "a",
target: "1",
label: "",
type: "tips",
class: "red",
},
},
{
data: {
source: "b",
target: "5",
label: "",
type: "tips",
class: "blue",
},
},
{ data: { source: "5", target: "4", label: "100%" } },
{ data: { source: "1", target: "2", label: "25.43%" } },
{ data: { source: "1", target: "3", label: "10%" } },
{ data: { source: "1", target: "6", label: "89.22%" } },
{ data: { source: "2", target: "6", label: "10.78%" } },
{ data: { source: "4", target: "3", label: "90%" } },
{ data: { source: "3", target: "2", label: "74.57%" } },
],
cy: {},
firstNodeBg: "#fd485d",
lastNodeBg: "#128bed",
currentHoverData: {},
showTips: false,
tippyArr: [],
tipsBg: {
blue: this.lastNodeBg,
red: this.firstNodeBg,
},
};
},
mounted() {
// 创建图
this.createCytoscape();
// 将第一个节点水平排列在同一行上,并为第一个节点添加说明
this.arrangeFirstNodeAndAddTips();
this.setTipsPosition();
cy.zoom(1);
// cy.$("#0").position("y", cy.$("#1").position("y") - 58);
this.addTapEventListener();
this.addMouseOverOutEvent();
this.addDragEventListenter();
this.addZoomEventListener();
},
beforeDestroy() {
this.tippyArr.map((item) => {
item.destroy();
});
},
methods: {
createCytoscape() {
cy = cytoscape({
container: document.getElementById("cy"), //设置容器
autounselectify: true,
layout: {
name: "dagre",
},
zoom: 0.1,
minZoom: 0.1,
maxZoom: 2,
fit: true,
boxSelectionEnabled: false,
style: [
{
selector: "node",
style: {
content: "data(label)",
shape: "rectangle",
width: 120,
padding: 5,
color: "#000",
boxSelectionEnabled: false,
"overlay-opacity": 0, // 取消默认的元素选择时的阴影
"text-max-width": 120,
"text-wrap": "wrap",
"text-overflow-wrap": "anywhere",
"line-height": 1.5,
"text-valign": "center",
"text-halign": "center",
"background-color": "#fff",
"border-width": 0.5,
"border-color": this.lastNodeBg,
"font-size": 12,
"font-weight": "lighter",
// "min-zoomed-font-size": 12,
},
},
{
selector:
"node[class='first-node'],node[class='red'],node[class='blue']",
style: {
shape: "ellipse",
color: "white",
width: 30,
height: 30,
padding: 8,
"text-opacity": 1,
"border-width": 0,
"background-color": this.firstNodeBg,
},
},
{
selector: "node[class='last-node']",
style: {
color: "white",
"background-color": this.lastNodeBg,
},
},
{
selector: "node[type='tips']",
style: {
width: 120,
height: 40,
shape: "rectangle",
color: "#fff",
"background-color": this.firstNodeBg,
},
},
{
selector: "node[class='red']",
style: {
"background-color": this.firstNodeBg,
},
},
{
selector: "node[class='blue']",
style: {
"background-color": this.lastNodeBg,
},
},
{
selector: "edge",
style: {
width: 1,
label: "data(label)",
color: this.lastNodeBg,
"target-arrow-shape": "triangle",
"line-color": "#ccc",
"target-arrow-color": this.lastNodeBg,
"arrow-scale": 0.7,
"curve-style": "bezier",
"font-size": 12,
"font-weight": "lighter",
"text-background-color": "#fff",
"text-overflow-wrap": "anywhere",
"text-background-opacity": 1,
"overlay-opacity": 0,
},
},
{
selector: "edge[type='tips']",
style: {
"line-color": "#fff",
"arrow-scale": 0.8,
},
},
{
selector: "edge[class='red']",
style: {
"target-arrow-color": this.firstNodeBg,
},
},
{
selector: "edge[class='blue']",
style: {
"target-arrow-color": this.lastNodeBg,
},
},
],
elements: {
nodes: this.nodes,
edges: this.edges,
},
});
},
getColor(firstNodeType) {
return firstNodeType === "person" ? this.firstNodeBg : this.lastNodeBg;
},
addTips(ele, dataObj) {
let tippyA = this.makeTippy(ele, dataObj);
tippyA.show();
},
arrangeFirstNodeAndAddTips() {
let firstNodePositionArr = [];
// 获取第一个节点的y坐标
cy.elements().map((item, index, el) => {
if (item.data().class === "first-node") {
firstNodePositionArr.push(cy.$(`#${item.id()}`).position("y"));
// this.addTips(item, item.data());
}
});
// 获取最小的y坐标
let minY = Math.min(...firstNodePositionArr);
// 将所有第一个节点的y坐标与最小的y坐标对齐
cy.elements().map((item, index, el) => {
if (item.data().class === "first-node") {
cy.$(`#${item.id()}`).position("y", minY);
}
});
},
makeTippy(ele, dataObj) {
let ref = ele.popperRef();
let dummyDomEle = document.createElement("div");
let tip = new tippy(dummyDomEle, {
getReferenceClientRect: ref.getBoundingClientRect,
arrow: true,
allowHTML: true,
trigger: "manual", // mandatory
content: this.createDescriptionTip(dataObj),
placement: "top",
hideOnClick: false,
sticky: "reference",
plugins: [sticky], // 必须添加
interactive: true,
interactive: true,
appendTo: document.body,
});
this.tippyArr.push(tip);
return tip;
},
clickNode(e) {
let node = e.target;
this.setTipContainersOpacity(0.2);
// 获取与点击节点相关的元素(包括节点和连线)
let relativeEle = cy.$(`#${node.id()}`).successors();
relativeEle.map((item, index, el) => {
el.style("opacity", "1");
});
let allEle = cy.elements();
// 获取除点击节点相关的元素(包括节点和连线)以外的节点和边,
// 会包括点击元素本身,将其透明度降低
let leftEle = allEle.difference(relativeEle);
leftEle.map((item, index, el) => {
el.style("opacity", 0.2);
});
// 当前点击的是第一个节点,则将第一个节点的提示描述显示出来
if (e.target.data().class == "first-node") {
let tipContainer = document.getElementById(
`tipsContainer${e.target.id()}`
);
tipContainer.style.opacity = 1;
}
// 将被点击的元素透明度置为1
node.style("opacity", 1);
},
createDescriptionTip(dataObj) {
let color = this.getColor(dataObj.type);
// 绘制文本
let tipContainer = document.createElement("div");
tipContainer.classList.add("first-node-description-container");
tipContainer.innerHTML = dataObj.label;
tipContainer.style.background = color;
tipContainer.setAttribute("id", `tipsContainer${dataObj.id}`);
// 绘制提示小箭头
let arrowDiv = document.createElement("div");
arrowDiv.classList.add("first-node-description-arrow");
arrowDiv.style.borderColor = `${color} transparent transparent transparent`;
tipContainer.appendChild(arrowDiv);
return tipContainer;
},
addTapEventListener() {
cy.on("tap", (e) => {
// 点击节点以外的地方,则恢复所有图的展示
if (e.target.data().class === "last-node") return;
if (e.target === cy || e.target.isEdge()) {
cy.elements().map((item, index, el) => {
el.style("opacity", 1);
});
this.setTipContainersOpacity(1);
} else {
this.clickNode(e);
}
});
},
addMouseOverOutEvent() {
cy.on("mouseover", "node", (e) => {
if (e.target.data().type !== "tips") {
this.showTips = true;
this.currentHoverData = e.target.data();
this.$nextTick(() => {
this.moveTipsContainer(e);
});
}
});
cy.on("mouseout", "node", (e) => {
this.showTips = false;
});
},
addDragEventListenter() {
cy.on("drag", "node", (e) => {
// this.moveTipsContainer(e);
this.setTipsPosition();
});
},
addZoomEventListener() {
cy.on("zoom", (e) => {
// console.log(cy.zoom());
// this.setTipsPosition();
});
},
setTipsPosition() {
cy.filter("node[type='tips']").map((item) => {
item.unlock();
cy.$(`#${item.id()}`).position(
"y",
cy.$(`#${item.data().target}`).position("y") - 58
);
cy.$(`#${item.id()}`).position(
"x",
cy.$(`#${item.data().target}`).position("x")
);
item.lock();
});
/*
// tippy方式
cy.filter("node[class='first-node']").map((item, i, ele) => {
let tipContainer = document.getElementById(
`tipsContainer${item.data().id}`
);
console.log("x=", item.position().x);
console.log("w=", item.width());
console.log("tipW=", tipContainer.clientWidth / 2 + "px");
tipContainer.style.transform = `scale(${cy.zoom()})`;
tipContainer.style.top =
item.position().y -
item.height() -
tipContainer.clientHeight -
30 +
"px";
tipContainer.style.left =
item.position().x -
item.width() -
tipContainer.clientWidth / 2 +
"px";
console.log("left=", tipContainer.style.left);
});*/
},
// 设置提示的位置
moveTipsContainer(e) {
this.tipContainerX =
e.target.renderedPosition().x - e.target.width() / 2 + "px";
this.tipContainerY =
e.target.renderedPosition().y + e.target.height() - 10 + "px";
},
setTipContainersOpacity(opacity) {
let tipsContainerList = document.getElementsByClassName(
"first-node-description-container"
);
for (let i = 0; i < tipsContainerList.length; i++) {
tipsContainerList[i].style.opacity = opacity;
}
},
hoverTips() {
this.showTips = true;
},
mouseoutTips() {
this.showTips = false;
},
},
};
</script>
<style>
#box {
width: 100%;
height: calc(100vh - 100px);
position: relative;
}
#cy {
width: 90%;
height: 100%;
position: absolute;
top: 100px;
z-index: 999;
}
#tipsWrapper {
box-shadow: 0px 0px 4px #ccc;
width: 300px;
padding: 10px 0px;
position: absolute;
background: #fff;
z-index: 3;
}
.__________cytoscape_container {
position: relative !important;
}
canvas {
left: 0 !important;
}
h1 {
font-size: 1em;
font-weight: normal;
}
/* makes sticky faster; disable if you want animated tippies */
.tippy-popper {
transition: none !important;
}
.first-node-description-container {
color: #fff;
padding: 5px 5px;
font-size: 18px;
position: absolute;
width: 100px;
text-align: center;
}
.first-node-description-arrow {
position: absolute;
border: 5px solid #fff;
top: calc(100% - 1px);
left: 50%;
transform: translateX(-50%);
}
</style>
更多推荐
已为社区贡献1条内容
所有评论(0)