【web系列十四】Jsplumb画布使用方法

目录

写在前面

Jsplumb介绍

jsplumb是什么

安装和导入方式

基本概念

主要方法介绍

getInstance()

addEndpoint()

draggable()

开发建议

jsplumb开发实战

实现关系图的显示

数据

数据转换

template

添加端点endpoint

添加连接线connector

实际效果

 整体代码

jsplumb+D3实现自动布局

D3是什么

D3 的优势

安装和导入

 数据转换

 d3生成树状图(自动布局)

其他细节 

效果展示

 整体代码

jsplumb+D3更多交互

修改数据格式

优化页面交互效果

双击弹出菜单

添加、删除操作

更新本地数据

整体代码

参考资料


写在前面

        博主最近的需要实现一个前端绘制拓扑图的工具,并且要求能够编辑节点的信息,以及将拓扑结构传给后端。首先想到的就是Jsplumb,这边记录一下开发过程中的一些知识点,本文中的代码都是基于vue3+ts写的。

Jsplumb介绍

        先给大家推荐一些写的不错的文章和文档。

jsPlumb初认识 - 知乎 (zhihu.com)

jsPlumb 基本概念 - 简书 (jianshu.com)

Overview | jsPlumb Documentation (jsplumbtoolkit.com)

jsplumb 中文基础教程 (wdd.js.org)

jsplumb是什么

        一句话来说就是一个在web端绘制关系图的开源工具。 

安装和导入方式

       安装很简单,直接使用以下命令即可

npm install jsplumb --save

        导入工具

        这里要注意,ready函数是jsplumb的初始化函数,由于jsplumb是基于dom元素的,因此在dom元素挂载上之前jsplumb是无法工作的,所以需要将ready函数放在onMounted函数下。而getInstance函数可以选择放在onMounted也可以放在onBeforeMount中。

基本概念

  • Anchor 锚点:表示一个dom元素上的位置,endpoint可以存在于这个位置。
  • Endpoint 端点:connector一端的可视化表示。
  • Connector 连接:用于连接两个dom元素的可视化表示。
  • Overlays 遮罩层:用于修饰连接器的UI组件,如标签、箭头等。一个连接器上可以同时存在多个overlays。

        官方文档用一句话概括了这些元素

In summary - one Connection is made up of two Endpoints, a Connector, and zero or more Overlays working together to join two elements. Each Endpoint has an associated Anchor.

        翻译过来就是:

总之,一个Connection由两个端点(endpoint)、一个Connector和零个或多个overlay组成,它们一起连接两个元素。每个端点都有一个关联的锚。

主要方法介绍

getInstance()

        创建一个实例

addEndpoint()

        向已有元素增加端点。

draggable()

        设置元素是否可拖拽,并可以限制拖拽区域。

开发建议

         jsplumb的功能还是很强大的,这里不可能完全罗列出来,也没有必要,但是为了帮助大家更快的上手jsplumb的开发,我在这里提几个建议。

  1. 首先了解一下jsplumb大概能做什么事,有什么基本概念,这块其实就看博主前面写的内容就够了。
  2. 根据开发的需求去官网文档查看是否有满足需求的功能/方法,博主在文中贴出的中文文档不是那么全,但是也可以参考,如果没有找到可以去看那篇英文的。
  3. 功能/方法的语法直接从jsplumb库的index.d.ts中查看,index.d.ts中记录了这个库所有的接口及接口的使用方法,基本上能解决90%的语法问题。
  4. 如果还是有问题,再百度查阅资料。博主也会在后面的篇幅中,给出一些具体的开发实例,大家可以参考,希望能对大家有帮助。

jsplumb开发实战

实现关系图的显示

数据

        首先给出数据,说明一下数据的含义:id、name、value都是指当前节点的属性,in_id表示指向当前节点的另一个节点的id。input表示输入节点的id,output表示的节点会指向一个输出节点。

let input: number[] = [-1];
let output: number[] = [6, 7];
let blocks: PlumbBlock[] = [
	{
		id: 0,
		in_id: [-1],
		name: "block",
		value: ["3"]
	},
	{
		id: 1,
		in_id: [0],
		name: "block-long",
		value: ["32"]
	},
	{
		id: 2,
		in_id: [1],
		name: "block",
		value: ["64"]
	},
	{
		id: 3,
		in_id: [2],
		name: "block-long",
		value: ["64"]
	},
	{
		id: 4,
		in_id: [1, 3],
		name: "block",
		value: ["128"]
	},
	{
		id: 5,
		in_id: [4],
		name: "block-long",
		value: ["128"]
	},
	{
		id: 6,
		in_id: [2, 5],
		name: "block",
		value: ["256"]
	},
	{
		id: 7,
		in_id: [6],
		name: "block-long",
		value: ["256"]
	}
];

数据转换

        可以看到input和output节点的表示与其他blocks是不同的,并且为了展示的时候这些节点是分开的而不是堆叠在一起,这边需要先做一个数据格式的转换,生成node_list和line_list两个列表,第一个列表包含所有的节点信息(包含了节点的位置偏移信息),第二个列表表示节点之间的连接关系。

export interface PlumbBlock {
	id: number;
	name: string;
	in_id?: number[] | undefined;
	value?: string[];
	top?: string;
};

let node_list: PlumbBlock[] = [];
let line_list: number[][] = [];  // [Source, Target]

function setNodeInfo(): void {
	let count: number = 0;
	let step: number = 75;
	
	for(let i = 0; i < input.length; ++i) {
		node_list.push({
			id: input[i],
			name: "input",
			top: count * step + "px",
		});
		++count;
	};
	
	for(let i = 0; i < blocks.length; ++i) {
		node_list.push({
			id: blocks[i].id,
			in_id: blocks[i].in_id,
			name: blocks[i].name,
			value: blocks[i].value,
			top: count * step + "px",
		});
		++count;
		
		let in_id: number[] | undefined = blocks[i].in_id;
		if(in_id != undefined) {
			for(let j = 0; j < in_id.length; ++j) 
				line_list.push([in_id[j], blocks[i].id);
		}
	};
	
	for(let i = 0; i < output.length; ++i) {
		let id = blocks.length + i;
		node_list.push({
			id: id,
			name: "output",
			top: count * step + "px",
		});
		++count;
		
		line_list.push([output[i], id);
	};
};

template

        这里使用一个v-for循环来渲染这些数据,也就是说给每个数据生成一个dom元素。这样jsplumb才能工作。



        这里注意两个踩坑点:

  1. 每个节点nodes设置了position:absolute属性,这是实现可拖拽节点的必备属性。
  2. 画布区域jsplumb也设置了position:absolute属性,这是为了保证可拖拽节点的拖拽区域限制是绝对的,而不是相对整个页面的。也就是说,如果画布jsplumb在整个页面上是偏移的,这时候不设置position:absolute的话,当给可拖拽节点设置拖拽区域为jsplumb时,你会发现实际的可拖拽区域是从整个页面的左上角开始算的,这样并不符合我们的需求。

添加端点endpoint

        为每个节点添加端点,注意输入节点input只添加Source端点,输出节点output只添加Target端点,其他节点需要同时添加两类端点。

function addEndpoints(): void {
	for(let i = 0; i < node_list.length; ++i) {
		let el = (document.getElementById(String(node_list[i].id)));
		
		jsPlumb_instance.draggable(el, { containment: "jsplumb" });
		if(node_list[i].name != "input") {
			jsPlumb_instance.addEndpoint(el, {
				isTarget: true,
				anchor: "Top",
				endpoint: ["Dot", { radius: 3 }],
			});
		}
		if(node_list[i].name != "output") {
			jsPlumb_instance.addEndpoint(el, {
				isSource: true,
				anchor: "Bottom",
				endpoint: ["Dot", { radius: 3 }],
			});
		}
	}
};

添加连接线connector

        这里可以看到我将一些公共属性提取出来了,作为一个参数传入,这是一个小技巧。

let connect_common = {
	connector: ["Bezier", { curviness: 15 }],
	anchor: ["Top", "Bottom"],
	
	endpoint: ["Dot", { radius: 3 }],
	endpointStyle: {
		stroke: "black",
		fill: "white",
		strokeWidth: 2,
	},
};

function drawLines(): void {
	for(let i = 0; i < line_list.length; ++i) {
		let start: Element = document.getElementById(String(line_list[i][0]));
		let end: Element = document.getElementById(String(line_list[i][1]));
		
		jsPlumb_instance.connect(
			{
				source: start,
				target: end,
				overlays: [["Arrow", { width: 12, length: 12, location: 0.5 }]],
			},
			connect_common
		);
	}
};

        这里也有两个坑要注意:

  1. 当connector设置为"Bezier"时,其实他包含一个curviness属性用来设置弯曲程度,这个属性可以生效的,并且在官网上有说明,但是在index.d.ts文件中好像没有。
  2. 端点的尺寸问题,在添加端点和连接线的时候都可以设置,以"Dot"举例,尺寸参数时radius,他的默认值是10,如果你想改大一些,那么无论在端点还是连接线那里设置都是可以的,但是如果你想改小一点,你会发现只设置端点或者连接线处的属性是不会生效的。博主猜测是由于元素的端点和连接线的两端都生成了一个可视化图形,并且会互相覆盖,因此需要把两边的尺寸都改小才可以看到效果。

实际效果

        这样就可以显示拓扑结构了,并且可以随意拖拽这些节点。但是看到这里大家也会发现jsplumb一个比较大的问题,没有自动布局,其实也是有的,在jsplumbtoolkits中,但是需要付费。那如果不想花钱咋办呢,不要急,看下一章。

【web系列十四】Jsplumb画布使用方法_第1张图片

【web系列十四】Jsplumb画布使用方法_第2张图片

 整体代码





jsplumb+D3实现自动布局

D3是什么

        D3 的全称是 Data-Driven Document,可以理解为:由数据来决定绘图流程的程序设计模型。D3 是一个JavaScript的函数库,是用来做数据可视化的。将数据变成图形,要想用原生的 HTML、SVG、Canvas 来实现是烦琐和困难的。D3 为我们封装好这些,让开发者能更注重图表的布局和逻辑。

D3 的优势

        JavaScript 的前端可视化库,除了 D3 外还有不少:Highcharts、Echarts、Chart.js。它们可以看作一类的,共同特点是封装层次很高,能够非常简单地制作图表,但是给予开发者控制和设计的空间很少。封装层次太高自然会失去部分自由,但太低又会使程序过长,D3 在这一点上取得了平衡。

安装和导入

        直接使用以下命令。

npm install d3

        导入

import * as D3 from 'd3';

        但是如果你用了ts语言的话会报错Could not find a declaration file for module 'd3',因为d3是js写的。解决方法很简单,找到vue3工程下的shims-vue.d.ts文件,添加以下内容。

declare module 'd3'

 数据转换

        咱们想要使用d3,就必须遵循d3对数据格式的要求,这里我们会使用树状图,因此需要把原始数据转换成树的形式。

        顺便提一下为什么选择树状图,我想要绘制的图其实也不是完全的树状结构,而是更接近d3的网络结构图。因为树状图的子节点只会存在一个父节点,但是我希望子节点可以存在多个父节点,而网络结构图可以满足这个要求。那为啥不选网络结构图呢,因为我希望生成的图形层级结构更加清晰,这点是树状图的优势,所以关键就是怎么解决树状图不能存在多个父节点的问题了。不过还有一点需要提一下,树状图有一个缺点是不能存在多个输入节点,大家如果由这个需求的话,可能要考虑考虑用其他的图。

        首先,我们先不管这个问题哈,我们先要知道d3的树状图需要什么样的输入,我已经展示在下方了。注意这里要遵循一个原则,即使子节点存在于多个父节点下,也不能在树中存在等多个相同的子节点,否则你会发现绘制的图上有重复的节点,所以我们先根据原始数据的in_id与id的关系来生成树,当子节点存在多个父节点时,将子节点插在id较小的父节点下。最后得到的树应该就是像下面这样。

let blocks_tree: Object = {
	id: -1;
	name: "input",
	children: [
		{
			id: 0,
			name: "block",
			value: ["3"],
			children: [
				{
					id: 1,
					name: "block-long",
					value: ["32"],
					children: [
						{
							id: 2,
							name: "block",
							value: ["64"]
							children: [
								{
									id: 3,
									name: "block-long",
									value: ["64"],
								},
								{
									id: 6,
									name: "block",
									value: ["256"],
									children: [
										{
											id: 7,
											name: "block-long",
											value: ["256"],
											children: [
												{
													id: 9,
													name: "output",
												},
											],
										},
										{
											id: 8,
											name: "output",
										}
									],
								},
							],
						},
						{
							id: 4,
							name: "block",
							value: ["128"],
							children: [
								id: 5,
								name: "block-long",
								value: ["128"],
							],
						},
					],
				},
			],
		},
	],
};

        但是我们的原始数据格式是不变的,大家可以回到上面去看原始数据的格式,因此需要一个格式转换的算法,如下,细节说明已经在代码中标注了。

// 创建一个树的接口,必须是嵌套的
export interface BlockTree {
	id: number;
	name: string;
	value?: string[];
	children?: BlockTree[];
}

let blocks_tree: BlockTree = { id: -100, name: "unknown" };  // 初始化

function transform(): void {
	// 生成block_list,包含所有Block和input\output
	let block_list: PlumbBlock[] = [];
	
	block_list.push({
		id: input,
		name: "input",
	});
	
	for(let i = 0; i < blocks.length; ++i) {
		block_list.push({
			id: blocks[i].id,
			in_id: blocks[i].in_id,
			name: blocks[i].name,
			value: blocks[i].value,
		});
	}
	
	for(let i = 0; i < output.length; ++i) {
		let id = blocks.length + i;
		block_list.push({
			id: id,
			in_id: [output[i]],
			name: "output",
		});
	}
	
	// 生成树,根据block_list的in_id<->id的关系插入,当一个子级存在多个父级时,将子级插在id较小的父级下
	blocks_tree = { id: input, name: "input", children: [] }; // 初始化时直接把input插入
	for(let i = 0; i < block_list.length; ++i) {
		let in_id = block_list[i].in_id;
		let min: number = in_id[0]; // 除了input外都是存在in_id的,且循环不包含input
		
		for(let j = 1; j < in_id.length; ++j) {
			if(in_id[j] < min)
				min = in_id[j];
		}
		addChildren(blocks_tree, min, block_list[i]);
	}
}

function addChildren(tree: BlockTree, idx: number, block: PlumbBlock): void {
	let key: keyof BlockTree;
	let find: boolean = false;
	for(key in tree) {
		if(key == "id") {
			if(tree[key] != idx)
				break;  // id不对,直接不用继续比较了
			else
				find = true;  // 说明找到叶子了
		}
	}
	
	if(!find) {
		if('children' in tree)
			for(let i = 0; i < tree.children.length; ++i)
				addChildren(tree.children[i], idx, block);
	}
	else {	// 找到叶子后把新的block插在叶子的children属性中
		// 确保有children属性
		if(!('children' in tree))
			tree.children = [];
			
		if('value' in block)
			tree.children.push({
				id: block.id,
				name: block.name,
				value: block.value,
			});
		else
			tree.children.push({
				id: block.id,
				name: block.name,
			});
	} 
}

 d3生成树状图(自动布局)

        得到了树结构数据后,就可以由d3来生成树状图了。

        这里就是解决之前那个问题的关键了,d3的树状图本来可以直接生成连接线的,使用以下函数。

let links = treeData.links();

lineList = links.map(item => {
	return {
		source: item.source.data.id,
		target: item.target.data.id,
	}
})

        而我这里放弃使用这个函数,而用原先的addEndpoints以及drawLines两个函数替代,这样就可以自由的生成连接线了。

// 修改node的生成方式,使用d3的树状图生成
function setNodeInfo(): void {
	let data = D3.hierachy(blocks_tree);
	
	let style = window.getcomputedStyle(document.getElementById('jsplumb')); // 获取元素的风格
	let canvas_width: number = Number(style.width.split('px')[0]);
	let canvas_height: number = Number(style.height.split('px')[0]);
	
	// 限制元素的位置
	let scale_width: number = 0.9;
	let scale_height: number = 0.9;

	// 创建树,根据jsplumb元素的尺寸来限制树的尺寸,这个size会和nodesize属性冲突
	let treeGenerator = D3.tree().size([canvas_width * scale_width, canvas_height * scale_height]);
	let treeData = treeGenerator(data);
	
	let nodes = treeData.descendants();
	
	node_list.value = nodes.map((item) => {
		return {
			id: item.data.id,
			name: item.data.name,
			left: item.x + "px",
			top: item.y + 20+ "px",
		};
	});
	
	for(let i = 0; i < blocks.length; ++i) {
		let in_id: number[] | undefined = blocks[i].in_id;
		if(in_id != undefined) {
			for(let j = 0; j < in_id.length; ++j) 
				line_list.push([in_id[j], blocks[i].id);
		}
	};
	
	for(let i = 0; i < output.length; ++i) 
		line_list.push([output[i], id);
};

其他细节 

1、由d3生成树状图包括了生成node,这个node的left和top都是由d3得到的,因此都要设置成变量,所以template和接口中也要做相应的改变。

:style="{left: item.left, top: item.top}"
export interface PlumbBlock {
	id: number;
	name: string;
	in_id?: number[] | undefined;
	value?: string[];
	left?: string;     // 用来控制元素的水平位置
	top?: string;
};

 2、为了方便控制生成的内容style,直接将common设置成jsplumb的全局属性了,但是要注意所有属性是首字母大写的

let common: Object = {
	Connector: ["Bezier", { curviness: 15 }],
	Overlays: [["Arrow", { width: 12, length: 12, location: 0.5 }]], 
	Anchor: ["AutoDefault"], // 这是由于自动布局的话,难以保证层级关系非常合理,容易产生输入输出的连接线出现在同一端点上的情况,使用autodefault可以看上去更加合理
	//anchor: ["Top", "Bottom"],
	
	Endpoint: ["Dot", { radius: 3 }],
	EndpointStyle: {
		stroke: "black",
		fill: "white",
		strokeWidth: 2,
	},
};

 3、函数的位置也很关键,我在下方标注了。

onBeforeonMount(() => {
	transform();  // 用来生成树
});

onMounted(() => {
	setNodeInfo();  // 移到这里是因为为了根据页面尺寸自适应的生成树,因此需要等jsplumb挂载完
    jsPlumb_instance = jsPlumb.getInstance();
	
	jsPlumb_instance.ready(function() {
		nextTick(() => {    // 增加nextTick是为了等待树的元素都挂载完,这样addEndpoints中才能给元素添加端点
			addEndpoints();
			drawLines();
		})
	});
});

效果展示

        效果如下,是不是还不错。

【web系列十四】Jsplumb画布使用方法_第3张图片

 整体代码





jsplumb+D3更多交互

修改数据格式

        为了便于存储和更新数据,也为了让逻辑更加清晰,先对数据格式做了以下修改:

1、拆分PlumbBlock接口,PlumbBlock为存储原始数据的最小单元,PlumbNode为画布显示的最小单元

interface PlumbBlock {
	id: number;
	name: string;
	in_id?: number[] | undefined;
	value?: string[];
};

interface PlumbNode {
	id: number;
	name: string;
	left: string;
	top: string;
}

2、将input、output、blocks合并成一个block_list

let block_list: PlumbBlock = [];
block_list = [
	{
		id: -1,
		name: "input",
	},
	{
		id: 0,
		in_id: [-1],
		name: "block",
		value: ["3"]
	},
	{
		id: 1,
		in_id: [0],
		name: "block-long",
		value: ["32"]
	},
	{
		id: 2,
		in_id: [1],
		name: "block",
		value: ["64"]
	},
	{
		id: 3,
		in_id: [2],
		name: "block-long",
		value: ["64"]
	},
	{
		id: 4,
		in_id: [1, 3],
		name: "block",
		value: ["128"]
	},
	{
		id: 5,
		in_id: [4],
		name: "block-long",
		value: ["128"]
	},
	{
		id: 6,
		in_id: [2, 5],
		name: "block",
		value: ["256"]
	},
	{
		id: 7,
		in_id: [6],
		name: "block-long",
		value: ["256"]
	},
	{
		id: 8,
		in_id: [6],
		name: "output"
	},
	{
		id: 9,
		in_id: [7],
		name: "output"
	}
];

优化页面交互效果

       修改jsplumb默认配置、template和css,实现鼠标悬停可以高亮节点和连线。





        正常界面。

【web系列十四】Jsplumb画布使用方法_第4张图片

         鼠标悬停在节点上。

【web系列十四】Jsplumb画布使用方法_第5张图片

         鼠标悬停在连接线上。

【web系列十四】Jsplumb画布使用方法_第6张图片

双击弹出菜单

        双击节点或连接线,可以弹出操作菜单。





         双击节点

【web系列十四】Jsplumb画布使用方法_第7张图片

         双击连接线

【web系列十四】Jsplumb画布使用方法_第8张图片

添加、删除操作

         实现节点或连接线的添加和删除。

// 用于区分Dom元素的类型,做逻辑判断用
enum DomType {
	empty,
	node,
	connection
}

let cur_type: DomType = DomType.empty;
let cur_source_id: string = ""; 
let cur_target_id: string = "";

function remove(): void {
	if(cur_type === DomType.node && cur_source_id !== "") {
		let find: boolean = false;
		for (let i = 1; i < block_list.length; ++i) {
			if(String(block_list[i].id) === cur_source_id) {
				block_list.splice(i, 1);
				find = true;
				break;
			}
		}
		// 删除节点及其连接线
		jsPlumb_instance.remove(cur_source_id);
	}
	else if(cur_type === DomType.connect && cur_source_id !== "" && cur_target_id !== "") {
		let connections = jsPlumb_instance.getAllConnections();  // 找到所有连接
		for(let idx in connections) {
			if(connections[idx].sourceId === cur_source_id && connections[idx].targetId === cur_target_id) {   // 筛选出首尾相同的连接并删除
				jsPlumb_instance.deleteConnection(connections[idx]);
				break;
			}
		}
	}
	show.value = false;
	cur_type = DomType.empty;
}

function cancel(): void {
	show.value = false;
	cur_type = DomType.empty;
}

// 连接线事件都在这儿
function bindEvent(): void {
	// 连接节点,删除重复连接,并确保output节点只有一个输入
	jsPlumb_instance.bind("connection", function(info) {
		// target是output
		for (let i = 1; i < block_list.length; ++i) {
			if(String(block_list[i].id) === info.targetId && block_list[i].name === "output" && block_list[i].in_id.length > 0) {
				let arr = jsPlumb_instance.select({ target: info.targetId });
				if(arr.length > 1) {
					block_list.splice(i, 1);
					break;
				}
			}
		}
		// target不是output
		let arr = jsPlumb_instance.select({ source: info.sourceId, target: info.targetId });
		if(arr.length > 1)
			jsPlumb_instance.deleteConnection(info.connection);
		
		need_update.value = true;
	});
	
	// 删除连接线,更新数据
	jsPlumb_instance.bind("connectionDetached", function(info) {
		need_update.value = true;
	});
}

        添加连接线。

【web系列十四】Jsplumb画布使用方法_第9张图片

         删除节点。

【web系列十四】Jsplumb画布使用方法_第10张图片

更新本地数据

        获取画布上的连接信息,更新本地数据。

let need_update = ref(false);  // 由watch监视,决定是否更新数据

// 连接线事件都在这儿
function bindEvent(): void {
	// 连接节点,删除重复连接,并确保output节点只有一个输入
	jsPlumb_instance.bind("connection", function(info) {
        // ...
		need_update.value = true;
	});
	
	// 删除连接线,更新数据
	jsPlumb_instance.bind("connectionDetached", function(info) {
		need_update.value = true;
	});
}

// 更新block_list中的连接关系
function update(): void {
	let connections = jsPlumb_instance.getAllConnections();
	// 先清空
	for(let i = 1; i < block_list.length; ++i)
		block_list[i].in_id.splice(0, block_list[i].in_id.length);
		
	// 插入新的连接线
	for(let i = 0; i < connections.length; ++i) {
		for(let j = 1; j < block_list.length; ++j) {
			if(String(block_list[j].id) === connection[i].targetId)
				block_list[j].in_id.push(Number(connections[i].sourceId));
		}
	}
	need_update.value = false;
}

// 监视need_update
watch(
	() => need_update.value,
	(cur: boolean, pre: boolean) => {
		if(cur === true)
			update();
	}
);

         删除一个节点后更新数据,可以看到id为1的节点和相关的in_id没有了。

【web系列十四】Jsplumb画布使用方法_第11张图片

整体代码





参考资料

jsPlumb初认识 - 知乎 (zhihu.com)

jsPlumb 基本概念 - 简书 (jianshu.com)

Overview | jsPlumb Documentation (jsplumbtoolkit.com)

 jsplumb 中文基础教程 (wdd.js.org)

你可能感兴趣的:(Web开荒,前端,javascript,流程图,vue)