jsplumb +Angular 实现指标血缘关系流程图

1,393 阅读5分钟

1. 需求描述

新增一个tab栏,实现“指标血缘”功能。

凸显这个核心指标,然后能够看到以这个核心指标为中心的一个辐射情况,前置依赖哪些指标,后续哪些指标依赖这个核心指标(后续依赖部分可以默认隐藏,然后用户可以展开查看)

可以追溯基于指标定义的指标引用&被引用关系

image.png

指标血缘展示指标名称及内容,内容根据指标类型区分。

现在分为四类:

  • 基础指标,展示指标主题 + 取数说明
  • 复合指标,展示指标计算逻辑 + 取数说明
  • 填报指标,展示填报指标 + 取数说明
  • 常数指标,展示常数指标 + 取数说明

2. 初识 jsPlumb

根据需求不难发现,需要我们来画一个流程图来表明指标血缘关系的展示。经过一番调研,我选用了 jsPlumb 这个库。

2.1 什么是 jsPlumb

jsPlumb是一个强大得JavaScript连线库,它提供了 html 元素的拖放、连线等功能,可绘制不同类型、样式的连线,适用于开发web页面的图表、建模工具等。同时也支持Vue、React和Angular 。

2.2 安装与引入

我在项目中用的是 2.15.6 版本

npm install jsplumb --save // npm安装
yarn add jsplumb // yarn安装

在组件中引入

import { jsPlumb } from "jsplumb";

2.3 基本元素组成

  • Source: 源对象(即连线的起始位置),jsPlumb 通过元素的 id 属性获取对象
  • Target: 目标对象(即连线的结束位置),jsPlumb 通过元素的 id 属性获取对象
  • Connector:连接线(即DOM节点间的连线)
  • Endpoint: 端点(即DOM节点上可以拖拽出连线的位置)
  • Anchor: 连线末端的位置
  • Overlay:连线相关的样式、信息等(添加到连接线上的附件,eg:箭头和标签)

2.4 常用方法

  1. ready() 确保jsPlumb 插件已经开始渲染
jsPlumb.ready(function() {
    ...
});
  1. batch() 绘制节点以及节点相关信息
jsPlumb.batch(function() {
  for (var i = 0, j = connections.length; i < j; i++) {
      jsPlumb.connect(connections[i]);
  }
});
  1. bind() jsPlumb 节点渲染完成后,在这里可以添加事件,在初始化时,直接添加事件
 // 连线事件
 jsPlumb.bind("connection", (info, event) => {
    this.bindLinkEvent(info.connection);
    this.data.links.push([info.sourceId, info.targetId]);
  });
  1. getInstance() jsPlumb默认注册在浏览器的window对象中,为整个页面提供了一个静态实例(jsPlumb)可以直接使用,当然你也可以使用getInstance方法来单独创建一个实例。即:
this.flowInst = jsPlumb.getInstance();

同时,getInstance 方法接受一个参数,可以更改实例的配置

this.flowInst = jsPlumb.getInstance({
    Connector : [ "Bezier", { curviness: 150 } ],
    Anchors : [ "TopCenter", "BottomCenter" ],
    ...
});
  1. jsPlumb.connect(…) 用于创建连线
this.flowInst.connect({
    source: 'item_left', // 源
    target: 'item_right', // 目标
    endpoint: 'Dot' // 线的类型
})
  1. jsPlumb.addEndpoint(…) 用来增加端点
// id: 增加端点得id
// common:端点的配置信息
this.flowInst.addEndpoint(id,{common})
this.flowInst.addEndpoint('item_left', {
    anchors: ['Right']
})
  1. jsPlumb.draggable() 节点是否可拖拽
let common = { 
    containment?: string
    start?: (params:DragEventCallbackOptions) => void
    drag?: (params:DragEventCallbackOptions) => void
    stop?: (params:DragEventCallbackOptions) => void
    cursor?: string
    zIndex?: number
}
this.flowInst.draggable(node._id, {common});

此方法有两个参数:

第一个参数为可拖拽节点的id,

第二个参数为函数对象,有6个参数,start,drag,stop 三个函数中可以获取元素节点位置等。

2.5 常用配置

{
    Anchor: "BottomCenter", //锚点位置,如left,top,bottom等;对任何没有声明描点的Endpoint设置锚点,用于source及诶单或target节点
    Anchors: [ null, null ], //连线的source和target Anchor
    ConnectionsDetachable: true, //连线是否可用鼠标分离
    ConnectionOverlays: [  //连线的叠加组件,如箭头、标签
        ["Arrow", {  //箭头参数设置
            location: 1,
            visible:true,
            width:11,
            length:11,
            id:"ARROW",
            events:{
                click:function() { }
            }
        } ],
        [ "Label", {  //标签参数设置
            location: 0.1,
            id: "label",
            cssClass: "aLabel", //hover时label的样式名
            events:{
                tap:function() { }
            },
            visible: true
        }]
    ],
    Connector: "Bezier", //连线的类型,流程图(Flowchart)、贝塞尔曲线(Bezier)、直线(Straight)等
    Container: null, //父级元素id;假如页面元素所在上层不同,最外层父级一定要设置
    DoNotThrowErrors: false, //如果请求不存在的Anchor、Endpoint或Connector,是否抛异常
    DragOptions: {cursor: 'pointer', zIndex: 2000}, //通过jsPlumb.draggable拖拽元素时的默认参数设置
    DropOptions: { }, //target Endpoint放置时的默认参数设置
    Endpoint: "Dot", //端点(锚点)的样式声明,Dot(默认圆点)、Rectangle(矩形)、Blank(空)
    Endpoints: [ null, null ], //用jsPlumb.connect创建连接时,source端点和target端点的样式设置
    EndpointOverlays: [ ], //端点的叠加物
    EndpointStyle: { fill : "#456" }, //端点的默认样式
    EndpointStyles: [ null, null ], //连线的source和target端点的样式
    EndpointHoverStyle: { fill: "#ec9f2e" }, //端点hover时的样式
    EndpointHoverStyles: [ null, null ], //连线的source和target端点hover时的样式
    HoverPaintStyle: {stroke: "#ec9f2e" }, //连线hover时的样式
    LabelStyle: { color: "black" }, //标签的默认样式,用css写法。
    LogEnabled: false, //是否开启jsPlumb内部日志
    Overlays: [ ], //连线和端点的叠加物
    MaxConnections: 1, //端点支持的最大连接数
    PaintStyle: { lineWidth : 8, stroke : "#456" }, //连线样式
    ReattachConnections: false, //是否重新连接使用鼠标分离的线?
    RenderMode: "svg", //默认渲染模式
    Scope: "jsPlumb_DefaultScope" //范围,具有相同scope的点才可连接?
}

3.开发过程

3.1 在组件中引入 jsPlumb 并定义基础设置

import 'jsplumb';
declare const jsPlumb: any;

// jsPlumb基础设置
const common = {
  endpoint: 'Blank',
  connector: ['Bezier', { curviness: 100 }],
  anchor: ['Left', 'Right'],
  maxConnections: -1
};

// jsPlumb连线箭头参数
const overlays = [
  ['Arrow', {
      width: 10,
      length: 12,
      location: 1
  }]
];

3.2 指标血缘设置连线

定义一些变量。因为该组件是个子组件,所以还要接收一些父组件传递过来的变量

public jsPlumbInstance: any; // jsPlumb 实例
public isShowQuote:boolean = false; // 是否展示被引用关系
...


@Input() public bloodKinshipIndicator: IBloodIndicatorDatasDto; // 血缘数据信息
...

IBloodIndicatorDatasDto 类型结构:

interface IBloodIndicatorDatasDto {
  indicator?: IBloodIndicatorData;
  beQuotedIndicators?: IBloodIndicatorData[];
  quotedIndicators?: IBloodIndicatorData[];
}

interface IBloodIndicatorData {
  indicatorId?: string;
  indicatorName?: string;
  indicatorType?: string;
  indicatorTypeName?: string;
  remark?: string;
  topicName?: string;
  formulaStr?: string;
  id?: string;
}

因为当前组件是个子组件,在 ngOnChanges 时期,给指标血缘关系连线

public bloodConnect(): void {
    this.jsPlumbInstance = jsPlumb.getInstance();
    this.jsPlumbInstance.ready(() => {
      let timer;
      timer = setTimeout(() => {
        clearTimeout(timer);
        this.bloodKinshipIndicator.beQuotedIndicators.forEach(item => {
          this.jsPlumbInstance.connect({
            source: item.indicatorId,
            target: 'indicator',
            paintStyle: {
                stroke: '#009FFF',
                strokeWidth: 2
            },
            overlays
          }, common);
        });
        if(this.isShowQuote && this.bloodKinshipIndicator.quotedIndicators.length) {
          this.quotedConnect(this.bloodKinshipIndicator.quotedIndicators);
        }
      }, 800);
    });
 }

以某个指标为例,后端返回的关于血缘的JSON数据:

image.png

indicator即当前指标,quotedIndicators 即引用指标数组,beQuotedIndicators 即被引用指标数组

3.3 设置被引用关系连接

public quotedConnect(data:IBloodIndicatorData[]): void {
    let timer;
    timer = setTimeout(() => {
      clearTimeout(timer);
      data.forEach(item => {
        this.jsPlumbInstance.connect({
          source: 'indicator',
          target: item.indicatorId,
          paintStyle: {
              stroke: '#355AF0',
              strokeWidth: 2
          },
          overlays
        }, common);
      });
      const element = document.getElementById('indicator');
      element.scrollIntoView({ behavior: 'smooth', block: 'center' });
    });
  }

3.4 是否展示被引用关系

public isShowQuoted(data:IBloodIndicatorData[]): void {
   this.isShowQuote = !this.isShowQuote;
   if(this.isShowQuote && data.length) {
     this.quotedConnect(data);
   } else {
     this.bloodKinshipIndicator.quotedIndicators.forEach(item => {
       this.jsPlumbInstance.deleteConnectionsForElement(item.indicatorId);
       this.jsPlumbInstance.removeAllEndpoints(item.indicatorId); // 删除divID所有端点
     });
   }
 }

该子组件的html代码,👇

...
<!-- 血缘 -->
<ng-template #KpibloodKinship let-type="type"  let-data="data">
 <div class="kpi-info kpi-blood">
  <div class="bottom-item">
    <div *ngFor="let item of data?.beQuotedIndicators;let i = index">
      <div class="left-item" id = "{{ item.indicatorId }}">
        <div class="son-item" nz-tooltip [nzTooltipTitle]="item?.indicatorName">{{ item?.indicatorName }}</div>
        <div class="left-bottom">
          <div *ngIf="item?.indicatorType === 'Indicator'">
            <div class="left-title">计算逻辑:</div>
            <div class="left-content" nz-tooltip [nzTooltipTitle]="item?.formulaStr">{{ item?.formulaStr}}</div>
          </div>
          <div *ngIf="item?.indicatorType !== 'Indicator'">
            <div class="blood-title" *ngIf="item?.indicatorType !== 'Db'">{{ item?.indicatorTypeName }}</div>
            <div class="blood-title" *ngIf="item?.indicatorType === 'Db'">{{ item?.topicName }}</div>
          </div>
          <div class="left-title">取数说明:</div>
          <div class="blood-remark" nz-tooltip [nzTooltipTitle]="item?.remark">{{ item?.remark }}</div>
        </div>
      </div>
    </div>
  </div>
  
  <div class="bottom-item">
    <div class="center-box" id="indicator">
      <div class="center-item" [ngClass]="getStringLength(data?.indicator.indicatorName)" nz-tooltip [nzTooltipTitle]="data?.indicator.indicatorName">{{data?.indicator.indicatorName}}</div>
      <span class="center-point" [ngClass]="{'center-point-close': isShowQuote}" *ngIf="data?.quotedIndicators.length" (click)="isShowQuoted(data?.quotedIndicators)"></span>
      <div class="center-bottom">
        <div *ngIf="data?.indicator.indicatorType === 'Indicator'">
          <div class="center-title">计算逻辑:</div>
          <div class="center-content" nz-tooltip [nzTooltipTitle]="data?.indicator.formulaStr">{{ data?.indicator.formulaStr}}</div>
        </div>
        <div *ngIf="data?.indicator.indicatorType !== 'Indicator'">
          <div class="blood-title" *ngIf="data?.indicator.indicatorType !== 'Db'">{{ data?.indicator.indicatorTypeName }}</div>
          <div class="blood-title" *ngIf="data?.indicator.indicatorType === 'Db'">{{ data?.indicator.topicName }}</div>
        </div>
        <div class="center-title">取数说明:</div>
        <div class="center-content" nz-tooltip [nzTooltipTitle]="data?.indicator.remark">{{ data?.indicator.remark }}</div>
      </div>
  </div>
  </div>
  
  <div class="bottom-item">
    <div class="right-box" *ngFor="let item of data?.quotedIndicators;let i = index">
      <div class="right-item" id="{{ item.indicatorId }}" [style.display]="isShowQuote ? 'block' : 'none'">
          <div class="right-son-item" nz-tooltip [nzTooltipTitle]="item?.indicatorName">{{ item?.indicatorName }}</div>
          <div class="right-bottom">
            <div *ngIf="item?.indicatorType === 'Indicator'">
              <div class="right-title">计算逻辑:</div>
              <div class="right-content" nz-tooltip [nzTooltipTitle]="item?.formulaStr">{{ item?.formulaStr}}</div>
            </div>
            <div *ngIf="item?.indicatorType !== 'Indicator'">
              <div class="blood-title" *ngIf="item?.indicatorType !== 'Db'">{{ item?.indicatorTypeName }}</div>
              <div class="blood-title" *ngIf="item?.indicatorType === 'Db'">{{ item?.topicName }}</div>
            </div>
            <div class="right-title">取数说明:</div>
            <div class="blood-remark" nz-tooltip [nzTooltipTitle]="item?.remark">{{ item?.remark }}</div>
          </div>
      </div>
    </div>
  </div>
 </div>
</ng-template>

该指标血缘效果图:

image.png

3.5 清除 jsplumb

public clearJsPlumb(flag?: boolean): void {
    this.isShowQuote = false;
    if(flag) {
      this.tabCode = '';
    }
    if(this.jsPlumbInstance) {
      this.jsPlumbInstance.deleteEveryConnection();
      this.jsPlumbInstance.deleteEveryEndpoint();
      this.jsPlumbInstance.reset();
    }
  }

在切换 tab 和关闭抽屉时,调用 clearJsPlumb 方法即可清除 jsplumb