1.需求

  • 绘制如爱企查所示的关系图,主要展示控股关系,图形按照控股链路层次递进,链路最长的企业放置在最下面;
  • 要求节点可拖动,拖动时连线和线上文本跟随拖动;
  • 鼠标悬浮显示节点相关信息;
  • 每条链路开始的节点顶部对齐;
  • 每条链路开始的节点顶部展示一个描述信息,该节点被拖动时,提示跟随拖动;
  • 点击每个节点,高亮展示从该节点开始往后的链路及节点,其余无关联节点和上层节点与连线透明度均降低;
  • 节点和节点之间可能出现环;
  • 数据是动态加载的;具体交互可以参考爱企查:https://aiqicha.baidu.com/relations/finalbenefit?pid=49113123525375
  • 综上,需要寻找支持类似树或流程图结构、节点样式可自定义,交互性强的绘图框架;
    在这里插入图片描述

2.调研

根据需求分析,主要需要实现一个类似流程图的结构。在网上查阅资料进行调研,主要就Echarts,d3,GoJs,Cytoscape进行了深入研究。

2.1 Echarts

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>
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>
Logo

前往低代码交流专区

更多推荐