@flowgram-vue/free-auto-layout-plugin 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +2713 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +377 -0
- package/dist/index.js +2705 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
- package/src/create-auto-layout-plugin.ts +23 -0
- package/src/dagre-layout/acyclic.ts +129 -0
- package/src/dagre-layout/graph.ts +71 -0
- package/src/dagre-layout/index.ts +9 -0
- package/src/dagre-layout/layout.ts +151 -0
- package/src/dagre-layout/order.ts +261 -0
- package/src/dagre-layout/rank/feasible-tree.ts +102 -0
- package/src/dagre-layout/rank/index.ts +9 -0
- package/src/dagre-layout/rank/longest-path.ts +79 -0
- package/src/dagre-layout/rank/network-simplex.ts +235 -0
- package/src/dagre-layout/rank/normalize-ranks.ts +26 -0
- package/src/dagre-layout/type.ts +43 -0
- package/src/dagre-lib/acyclic.js +72 -0
- package/src/dagre-lib/add-border-segments.js +41 -0
- package/src/dagre-lib/coordinate-system.js +77 -0
- package/src/dagre-lib/data/list.js +63 -0
- package/src/dagre-lib/debug.js +34 -0
- package/src/dagre-lib/greedy-fas.js +134 -0
- package/src/dagre-lib/index.js +75 -0
- package/src/dagre-lib/layout.js +449 -0
- package/src/dagre-lib/nesting-graph.js +133 -0
- package/src/dagre-lib/normalize.js +98 -0
- package/src/dagre-lib/order/add-subgraph-constraints.js +57 -0
- package/src/dagre-lib/order/barycenter.js +34 -0
- package/src/dagre-lib/order/build-layer-graph.js +80 -0
- package/src/dagre-lib/order/cross-count.js +78 -0
- package/src/dagre-lib/order/index.js +86 -0
- package/src/dagre-lib/order/init-order.js +43 -0
- package/src/dagre-lib/order/resolve-conflicts.js +128 -0
- package/src/dagre-lib/order/sort-subgraph.js +79 -0
- package/src/dagre-lib/order/sort.js +62 -0
- package/src/dagre-lib/parent-dummy-chains.js +90 -0
- package/src/dagre-lib/position/bk.js +431 -0
- package/src/dagre-lib/position/index.js +37 -0
- package/src/dagre-lib/rank/feasible-tree.js +104 -0
- package/src/dagre-lib/rank/index.js +61 -0
- package/src/dagre-lib/rank/network-simplex.js +243 -0
- package/src/dagre-lib/rank/util.js +70 -0
- package/src/dagre-lib/util.js +365 -0
- package/src/dagre-lib/version.js +6 -0
- package/src/env.d.ts +10 -0
- package/src/index.ts +10 -0
- package/src/layout/constant.ts +26 -0
- package/src/layout/dagre.ts +245 -0
- package/src/layout/index.ts +9 -0
- package/src/layout/layout.ts +41 -0
- package/src/layout/position.ts +72 -0
- package/src/layout/store.ts +262 -0
- package/src/layout/type.ts +162 -0
- package/src/services.ts +174 -0
- package/src/type.ts +10 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WorkflowDocument } from '@flowgram-vue/free-layout-core';
|
|
7
|
+
import { PositionSchema, startTween } from '@flowgram-vue/core';
|
|
8
|
+
|
|
9
|
+
import { LayoutNode } from './type';
|
|
10
|
+
import { LayoutStore } from './store';
|
|
11
|
+
|
|
12
|
+
export class LayoutPosition {
|
|
13
|
+
constructor(private readonly store: LayoutStore) {}
|
|
14
|
+
|
|
15
|
+
public async position(): Promise<void> {
|
|
16
|
+
if (this.store.options.enableAnimation) {
|
|
17
|
+
return this.positionWithAnimation();
|
|
18
|
+
}
|
|
19
|
+
return this.positionDirectly();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private positionDirectly(): void {
|
|
23
|
+
this.store.nodes.forEach((layoutNode) => {
|
|
24
|
+
this.updateNodePosition({ layoutNode, step: 100 });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async positionWithAnimation(): Promise<void> {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
startTween({
|
|
31
|
+
from: { d: 0 },
|
|
32
|
+
to: { d: 100 },
|
|
33
|
+
duration: this.store.options.animationDuration ?? 0,
|
|
34
|
+
onUpdate: (v) => {
|
|
35
|
+
this.store.nodes.forEach((layoutNode) => {
|
|
36
|
+
this.updateNodePosition({ layoutNode, step: v.d });
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
onComplete: () => {
|
|
40
|
+
resolve();
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private updateNodePosition(params: { layoutNode: LayoutNode; step: number }): void {
|
|
47
|
+
const { layoutNode, step } = params;
|
|
48
|
+
const { transform } = layoutNode.entity.transform;
|
|
49
|
+
|
|
50
|
+
const centerToTopEdgeOffset =
|
|
51
|
+
(layoutNode.size.height - layoutNode.padding.top - layoutNode.padding.bottom) / 2;
|
|
52
|
+
|
|
53
|
+
const layoutPosition: PositionSchema = {
|
|
54
|
+
x: layoutNode.position.x + layoutNode.offset.x,
|
|
55
|
+
y: layoutNode.position.y + layoutNode.offset.y - centerToTopEdgeOffset,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const deltaX = ((layoutPosition.x - transform.position.x) * step) / 100;
|
|
59
|
+
const deltaY = ((layoutPosition.y - transform.position.y) * step) / 100;
|
|
60
|
+
|
|
61
|
+
const position = {
|
|
62
|
+
x: transform.position.x + deltaX,
|
|
63
|
+
y: transform.position.y + deltaY,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
transform.update({
|
|
67
|
+
position,
|
|
68
|
+
});
|
|
69
|
+
const document = layoutNode.entity.document as WorkflowDocument;
|
|
70
|
+
document.layout.updateAffectedTransform(layoutNode.entity);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WorkflowNodeEntity } from '@flowgram-vue/free-layout-core';
|
|
7
|
+
import { FlowNodeBaseType } from '@flowgram-vue/document';
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
GetFollowNode,
|
|
11
|
+
ILayoutStore,
|
|
12
|
+
LayoutConfig,
|
|
13
|
+
LayoutEdge,
|
|
14
|
+
LayoutNode,
|
|
15
|
+
LayoutOptions,
|
|
16
|
+
LayoutParams,
|
|
17
|
+
LayoutStoreData,
|
|
18
|
+
} from './type';
|
|
19
|
+
|
|
20
|
+
export class LayoutStore implements ILayoutStore {
|
|
21
|
+
private indexMap: Map<string, string>;
|
|
22
|
+
|
|
23
|
+
private init: boolean = false;
|
|
24
|
+
|
|
25
|
+
private store: LayoutStoreData;
|
|
26
|
+
|
|
27
|
+
public options: LayoutOptions;
|
|
28
|
+
|
|
29
|
+
public container: LayoutNode;
|
|
30
|
+
|
|
31
|
+
constructor(public readonly config: LayoutConfig) {}
|
|
32
|
+
|
|
33
|
+
public get initialized(): boolean {
|
|
34
|
+
return this.init;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public getNode(id?: string): LayoutNode | undefined {
|
|
38
|
+
if (!id) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return this.store.nodes.get(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public getNodeByIndex(index: string): LayoutNode | undefined {
|
|
45
|
+
const id = this.indexMap.get(index);
|
|
46
|
+
return id ? this.getNode(id) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public getEdge(id: string): LayoutEdge | undefined {
|
|
50
|
+
return this.store.edges.get(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public get nodes(): LayoutNode[] {
|
|
54
|
+
return Array.from(this.store.nodes.values());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public get edges(): LayoutEdge[] {
|
|
58
|
+
return Array.from(this.store.edges.values());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public create(params: LayoutParams, options: LayoutOptions): void {
|
|
62
|
+
this.container = params.container;
|
|
63
|
+
this.store = this.createStore(params);
|
|
64
|
+
this.indexMap = this.createIndexMap();
|
|
65
|
+
this.setOptions(options);
|
|
66
|
+
this.init = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 创建布局数据 */
|
|
70
|
+
private createStore(params: LayoutParams): LayoutStoreData {
|
|
71
|
+
const { layoutNodes, layoutEdges } = params;
|
|
72
|
+
const virtualEdges = this.createVirtualEdges(params);
|
|
73
|
+
const store = {
|
|
74
|
+
nodes: new Map(),
|
|
75
|
+
edges: new Map(),
|
|
76
|
+
};
|
|
77
|
+
layoutNodes.forEach((node) => store.nodes.set(node.id, node));
|
|
78
|
+
layoutEdges.concat(virtualEdges).forEach((edge) => store.edges.set(edge.id, edge));
|
|
79
|
+
return store;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 创建虚拟线条数据 */
|
|
83
|
+
private createVirtualEdges(params: LayoutParams): LayoutEdge[] {
|
|
84
|
+
const { layoutNodes, layoutEdges } = params;
|
|
85
|
+
const nodes = layoutNodes.map((layoutNode) => layoutNode.entity);
|
|
86
|
+
const edges = layoutEdges.map((layoutEdge) => layoutEdge.entity);
|
|
87
|
+
const groupNodes = nodes.filter((n) => n.flowNodeType === FlowNodeBaseType.GROUP);
|
|
88
|
+
const virtualEdges = groupNodes
|
|
89
|
+
.map((group) => {
|
|
90
|
+
const { id: groupId, blocks = [] } = group;
|
|
91
|
+
const blockIdSet = new Set(blocks.map((b) => b.id));
|
|
92
|
+
const groupFromEdges = edges
|
|
93
|
+
.filter((edge) => blockIdSet.has(edge.to?.id ?? ''))
|
|
94
|
+
.map((edge) => {
|
|
95
|
+
const { from, to } = edge.info;
|
|
96
|
+
if (!from || !to) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const id = `virtual_${groupId}_from_${from}_to_${to}`;
|
|
100
|
+
const layoutEdge: LayoutEdge = {
|
|
101
|
+
id: id,
|
|
102
|
+
entity: edge,
|
|
103
|
+
from,
|
|
104
|
+
to: groupId,
|
|
105
|
+
fromIndex: '', // 初始化时,index 未计算
|
|
106
|
+
toIndex: '', // 初始化时,index 未计算
|
|
107
|
+
name: id,
|
|
108
|
+
};
|
|
109
|
+
return layoutEdge;
|
|
110
|
+
})
|
|
111
|
+
.filter(Boolean) as LayoutEdge[];
|
|
112
|
+
const groupToEdges = edges
|
|
113
|
+
.filter((edge) => blockIdSet.has(edge.from?.id ?? ''))
|
|
114
|
+
.map((edge) => {
|
|
115
|
+
const { from, to } = edge.info;
|
|
116
|
+
if (!from || !to) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const id = `virtual_${groupId}_from_${from}_to_${to}`;
|
|
120
|
+
const layoutEdge: LayoutEdge = {
|
|
121
|
+
id: id,
|
|
122
|
+
entity: edge,
|
|
123
|
+
from: groupId,
|
|
124
|
+
to,
|
|
125
|
+
fromIndex: '', // 初始化时,index 未计算
|
|
126
|
+
toIndex: '', // 初始化时,index 未计算
|
|
127
|
+
name: id,
|
|
128
|
+
};
|
|
129
|
+
return layoutEdge;
|
|
130
|
+
})
|
|
131
|
+
.filter(Boolean) as LayoutEdge[];
|
|
132
|
+
return [...groupFromEdges, ...groupToEdges];
|
|
133
|
+
})
|
|
134
|
+
.flat();
|
|
135
|
+
return virtualEdges;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 创建节点索引映射 */
|
|
139
|
+
private createIndexMap(): Map<string, string> {
|
|
140
|
+
const nodeIndexes = this.sortNodes();
|
|
141
|
+
const nodeToIndex = new Map<string, string>();
|
|
142
|
+
|
|
143
|
+
// 创建节点索引映射
|
|
144
|
+
nodeIndexes.forEach((nodeId, nodeIndex) => {
|
|
145
|
+
const node = this.getNode(nodeId);
|
|
146
|
+
if (!node) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const graphIndex = String(100000 + nodeIndex);
|
|
150
|
+
nodeToIndex.set(node.id, graphIndex);
|
|
151
|
+
node.index = graphIndex;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// 创建连线索引映射
|
|
155
|
+
this.edges.forEach((edge) => {
|
|
156
|
+
const fromIndex = nodeToIndex.get(edge.from);
|
|
157
|
+
const toIndex = nodeToIndex.get(edge.to);
|
|
158
|
+
if (!fromIndex || !toIndex) {
|
|
159
|
+
this.store.edges.delete(edge.id);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
edge.fromIndex = fromIndex;
|
|
163
|
+
edge.toIndex = toIndex;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// 创建索引到节点的映射
|
|
167
|
+
const indexToNode = new Map();
|
|
168
|
+
nodeToIndex.forEach((index, id) => {
|
|
169
|
+
indexToNode.set(index, id);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return indexToNode;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 节点排序 */
|
|
176
|
+
private sortNodes(): Array<string> {
|
|
177
|
+
// 节点 id 列表,id 可能重复
|
|
178
|
+
const nodeIdList: string[] = [];
|
|
179
|
+
|
|
180
|
+
// 第1级排序:按照 node 添加顺序排序
|
|
181
|
+
this.nodes.forEach((node) => {
|
|
182
|
+
nodeIdList.push(node.id);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// 第2级排序:被连线节点排序靠后
|
|
186
|
+
this.edges.forEach((edge) => {
|
|
187
|
+
nodeIdList.push(edge.to);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 第3级排序:按照从开始节点进行遍历排序
|
|
191
|
+
const visited = new Set<string>();
|
|
192
|
+
const visit = (node?: WorkflowNodeEntity) => {
|
|
193
|
+
if (!node || visited.has(node.id)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
visited.add(node.id);
|
|
197
|
+
nodeIdList.push(node.id);
|
|
198
|
+
// 访问子节点
|
|
199
|
+
node.blocks.forEach((child) => {
|
|
200
|
+
visit(child);
|
|
201
|
+
});
|
|
202
|
+
// 访问后续节点
|
|
203
|
+
const { outputLines } = node.lines;
|
|
204
|
+
const sortedLines = outputLines.sort((a, b) => {
|
|
205
|
+
const aNode = this.getNode(a.to?.id);
|
|
206
|
+
const bNode = this.getNode(b.to?.id);
|
|
207
|
+
const aPort = a.fromPort;
|
|
208
|
+
const bPort = b.fromPort;
|
|
209
|
+
// 同端口,对比to节点y轴坐标
|
|
210
|
+
if (aPort === bPort && aNode && bNode) {
|
|
211
|
+
return aNode.position.y - bNode.position.y;
|
|
212
|
+
}
|
|
213
|
+
// 同from节点的不同端口,对比端口y轴坐标
|
|
214
|
+
if (aPort && bPort) {
|
|
215
|
+
return aPort.point.y - bPort.point.y;
|
|
216
|
+
}
|
|
217
|
+
return 0;
|
|
218
|
+
});
|
|
219
|
+
sortedLines.forEach((line) => {
|
|
220
|
+
const { to } = line;
|
|
221
|
+
if (!to) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
visit(to);
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
visit(this.container.entity);
|
|
228
|
+
|
|
229
|
+
// 使用 reduceRight 去重并保留最后一个出现的节点 id
|
|
230
|
+
const uniqueNodeIds: string[] = nodeIdList.reduceRight((acc: string[], nodeId: string) => {
|
|
231
|
+
if (!acc.includes(nodeId)) {
|
|
232
|
+
acc.unshift(nodeId);
|
|
233
|
+
}
|
|
234
|
+
return acc;
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
return uniqueNodeIds;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 记录运行选项 */
|
|
241
|
+
private setOptions(options: LayoutOptions): void {
|
|
242
|
+
this.options = options;
|
|
243
|
+
this.setFollowNode(options.getFollowNode);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 设置跟随节点配置 */
|
|
247
|
+
private setFollowNode(getFollowNode?: GetFollowNode): void {
|
|
248
|
+
if (!getFollowNode) return;
|
|
249
|
+
const context = { store: this };
|
|
250
|
+
this.nodes.forEach((node) => {
|
|
251
|
+
const followTo = getFollowNode(node, context)?.followTo;
|
|
252
|
+
if (!followTo) return;
|
|
253
|
+
const followToNode = this.getNode(followTo);
|
|
254
|
+
if (!followToNode) return;
|
|
255
|
+
if (!followToNode.followedBy) {
|
|
256
|
+
followToNode.followedBy = [];
|
|
257
|
+
}
|
|
258
|
+
followToNode.followedBy.push(node.id);
|
|
259
|
+
node.followTo = followTo;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { WorkflowLineEntity, WorkflowNodeEntity } from '@flowgram-vue/free-layout-core';
|
|
7
|
+
|
|
8
|
+
export interface LayoutStoreData {
|
|
9
|
+
nodes: Map<string, LayoutNode>;
|
|
10
|
+
edges: Map<string, LayoutEdge>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ILayoutStore {
|
|
14
|
+
container: LayoutNode;
|
|
15
|
+
options: LayoutOptions;
|
|
16
|
+
get initialized(): boolean;
|
|
17
|
+
getNode(id?: string): LayoutNode | undefined;
|
|
18
|
+
getNodeByIndex(index: string): LayoutNode | undefined;
|
|
19
|
+
getEdge(id: string): LayoutEdge | undefined;
|
|
20
|
+
nodes: LayoutNode[];
|
|
21
|
+
edges: LayoutEdge[];
|
|
22
|
+
create(params: LayoutParams, options: LayoutOptions): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ILayout {
|
|
26
|
+
init(params: LayoutParams, options: LayoutOptions): void;
|
|
27
|
+
layout(): void;
|
|
28
|
+
position(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LayoutSize {
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LayoutNode {
|
|
37
|
+
id: string;
|
|
38
|
+
/** 节点索引 */
|
|
39
|
+
index: string;
|
|
40
|
+
/** 节点实体 */
|
|
41
|
+
entity: WorkflowNodeEntity;
|
|
42
|
+
/** 层级 */
|
|
43
|
+
rank: number;
|
|
44
|
+
/** 顺序 */
|
|
45
|
+
order: number;
|
|
46
|
+
/** 位置 */
|
|
47
|
+
position: {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
};
|
|
51
|
+
/** 偏移量 */
|
|
52
|
+
offset: {
|
|
53
|
+
x: number;
|
|
54
|
+
y: number;
|
|
55
|
+
};
|
|
56
|
+
/** 边距 */
|
|
57
|
+
padding: {
|
|
58
|
+
top: number;
|
|
59
|
+
bottom: number;
|
|
60
|
+
left: number;
|
|
61
|
+
right: number;
|
|
62
|
+
};
|
|
63
|
+
/** 宽高 */
|
|
64
|
+
size: LayoutSize;
|
|
65
|
+
/** 子节点 */
|
|
66
|
+
layoutNodes: LayoutNode[];
|
|
67
|
+
/** 子线条 */
|
|
68
|
+
layoutEdges: LayoutEdge[];
|
|
69
|
+
/** 被跟随节点 */
|
|
70
|
+
followedBy?: string[];
|
|
71
|
+
/** 跟随节点 */
|
|
72
|
+
followTo?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface LayoutEdge {
|
|
76
|
+
id: string;
|
|
77
|
+
/** 线条实体 */
|
|
78
|
+
entity: WorkflowLineEntity;
|
|
79
|
+
/** 起点 */
|
|
80
|
+
from: string;
|
|
81
|
+
/** 终点 */
|
|
82
|
+
to: string;
|
|
83
|
+
/** 起点索引 */
|
|
84
|
+
fromIndex: string;
|
|
85
|
+
/** 终点索引 */
|
|
86
|
+
toIndex: string;
|
|
87
|
+
/** 线条名称 */
|
|
88
|
+
name: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DagreNode {
|
|
92
|
+
width: number;
|
|
93
|
+
height: number;
|
|
94
|
+
x: number;
|
|
95
|
+
y: number;
|
|
96
|
+
order: number;
|
|
97
|
+
rank: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface LayoutParams {
|
|
101
|
+
container: LayoutNode;
|
|
102
|
+
layoutNodes: LayoutNode[];
|
|
103
|
+
layoutEdges: LayoutEdge[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface LayoutOptions {
|
|
107
|
+
/** Custom layout configuration to override default dagre settings. */
|
|
108
|
+
layoutConfig?: Partial<LayoutConfig>;
|
|
109
|
+
/** The container node entity used as the root for the layout. */
|
|
110
|
+
containerNode?: WorkflowNodeEntity;
|
|
111
|
+
/** Custom function to determine follow-node relationships between layout nodes. */
|
|
112
|
+
getFollowNode?: GetFollowNode;
|
|
113
|
+
/** Whether to animate node movements during layout positioning. */
|
|
114
|
+
enableAnimation?: boolean;
|
|
115
|
+
/** Duration of the position animation in milliseconds. Only effective when `enableAnimation` is true. */
|
|
116
|
+
animationDuration?: number;
|
|
117
|
+
/** When true, skips the fit-view step after layout is applied. */
|
|
118
|
+
disableFitView?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* When true, aligns nodes by their top edge instead of their center point.
|
|
121
|
+
* Defaults to false (center-aligned). Set to true to place all nodes' top edges on the same horizontal line.
|
|
122
|
+
*/
|
|
123
|
+
alignTopEdge?: boolean;
|
|
124
|
+
/** Filter function to exclude specific nodes from the layout. Return false to skip a node. */
|
|
125
|
+
filterNode?: (params: { node: WorkflowNodeEntity; parent?: WorkflowNodeEntity }) => boolean;
|
|
126
|
+
/** Filter function to exclude specific edges from the layout. Return false to skip an edge. */
|
|
127
|
+
filterLine?: (params: { line: WorkflowLineEntity }) => boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface LayoutConfig {
|
|
131
|
+
/** Direction for rank nodes. Can be TB, BT, LR, or RL, where T = top, B = bottom, L = left, and R = right. */
|
|
132
|
+
rankdir: 'TB' | 'BT' | 'LR' | 'RL';
|
|
133
|
+
/** Alignment for rank nodes. Can be UL, UR, DL, or DR, where U = up, D = down, L = left, and R = right. */
|
|
134
|
+
align: 'UL' | 'UR' | 'DL' | 'DR' | undefined;
|
|
135
|
+
/** Number of pixels that separate nodes horizontally in the layout. */
|
|
136
|
+
nodesep: number;
|
|
137
|
+
/** Number of pixels that separate edges horizontally in the layout. */
|
|
138
|
+
edgesep: number;
|
|
139
|
+
/** Number of pixels that separate edges horizontally in the layout. */
|
|
140
|
+
ranksep: number;
|
|
141
|
+
/** Number of pixels to use as a margin around the left and right of the graph. */
|
|
142
|
+
marginx: number;
|
|
143
|
+
/** Number of pixels to use as a margin around the top and bottom of the graph. */
|
|
144
|
+
marginy: number;
|
|
145
|
+
/** If set to greedy, uses a greedy heuristic for finding a feedback arc set for a graph. A feedback arc set is a set of edges that can be removed to make a graph acyclic. */
|
|
146
|
+
acyclicer: 'greedy' | undefined;
|
|
147
|
+
/** Type of algorithm to assigns a rank to each node in the input graph. Possible values: network-simplex, tight-tree or longest-path */
|
|
148
|
+
ranker: 'network-simplex' | 'tight-tree' | 'longest-path';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type GetFollowNode = (
|
|
152
|
+
node: LayoutNode,
|
|
153
|
+
context: {
|
|
154
|
+
store: ILayoutStore;
|
|
155
|
+
/** 业务自定义参数 */
|
|
156
|
+
[key: string]: any;
|
|
157
|
+
}
|
|
158
|
+
) =>
|
|
159
|
+
| {
|
|
160
|
+
followTo?: string;
|
|
161
|
+
}
|
|
162
|
+
| undefined;
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { inject, injectable } from 'inversify';
|
|
7
|
+
import { Rectangle } from '@flowgram-vue/utils';
|
|
8
|
+
import {
|
|
9
|
+
WorkflowDocument,
|
|
10
|
+
WorkflowLineEntity,
|
|
11
|
+
WorkflowNodeEntity,
|
|
12
|
+
WorkflowNodeLinesData,
|
|
13
|
+
} from '@flowgram-vue/free-layout-core';
|
|
14
|
+
import { Playground } from '@flowgram-vue/core';
|
|
15
|
+
|
|
16
|
+
import { AutoLayoutOptions } from './type';
|
|
17
|
+
import { LayoutConfig, LayoutEdge, LayoutNode } from './layout/type';
|
|
18
|
+
import { DefaultLayoutOptions } from './layout/constant';
|
|
19
|
+
import { DefaultLayoutConfig, Layout, type LayoutOptions } from './layout';
|
|
20
|
+
|
|
21
|
+
@injectable()
|
|
22
|
+
export class AutoLayoutService {
|
|
23
|
+
@inject(Playground)
|
|
24
|
+
private playground: Playground;
|
|
25
|
+
|
|
26
|
+
@inject(WorkflowDocument) private readonly document: WorkflowDocument;
|
|
27
|
+
|
|
28
|
+
private layoutConfig: LayoutConfig = DefaultLayoutConfig;
|
|
29
|
+
|
|
30
|
+
public init(options: AutoLayoutOptions) {
|
|
31
|
+
this.layoutConfig = {
|
|
32
|
+
...this.layoutConfig,
|
|
33
|
+
...options.layoutConfig,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public async layout(options: Partial<LayoutOptions> = {}): Promise<void> {
|
|
38
|
+
const layoutOptions: LayoutOptions = {
|
|
39
|
+
...DefaultLayoutOptions,
|
|
40
|
+
...options,
|
|
41
|
+
};
|
|
42
|
+
const containerNode = layoutOptions.containerNode ?? this.document.root;
|
|
43
|
+
const container = this.createLayoutNode(containerNode, options);
|
|
44
|
+
const layouts = await this.layoutNode(container, layoutOptions);
|
|
45
|
+
const rect = this.getLayoutNodeRect(container);
|
|
46
|
+
const positionPromise = layouts.map((layout) => layout.position());
|
|
47
|
+
const fitViewPromise = this.fitView(layoutOptions, rect);
|
|
48
|
+
await Promise.all([...positionPromise, fitViewPromise]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async fitView(options: LayoutOptions, rect: Rectangle): Promise<void> {
|
|
52
|
+
if (options.disableFitView === true) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// 留出 30 像素的边界
|
|
56
|
+
return this.playground.config.fitView(rect, options.enableAnimation, 30);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async layoutNode(container: LayoutNode, options: LayoutOptions): Promise<Layout[]> {
|
|
60
|
+
const { layoutNodes, layoutEdges } = container;
|
|
61
|
+
if (layoutNodes.length === 0) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
// 触发子节点布局
|
|
65
|
+
const childrenLayouts = (
|
|
66
|
+
await Promise.all(layoutNodes.map((n) => this.layoutNode(n, options)))
|
|
67
|
+
).flat();
|
|
68
|
+
const layoutConfig: LayoutConfig = {
|
|
69
|
+
...this.layoutConfig,
|
|
70
|
+
...options.layoutConfig,
|
|
71
|
+
};
|
|
72
|
+
const layout = new Layout(layoutConfig);
|
|
73
|
+
layout.init({ container, layoutNodes, layoutEdges }, options);
|
|
74
|
+
layout.layout();
|
|
75
|
+
const rect = this.getLayoutNodeRect(container);
|
|
76
|
+
container.size = {
|
|
77
|
+
width: rect.width,
|
|
78
|
+
height: rect.height,
|
|
79
|
+
};
|
|
80
|
+
return [...childrenLayouts, layout];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private createLayoutNodes(nodes: WorkflowNodeEntity[], options: LayoutOptions): LayoutNode[] {
|
|
84
|
+
return nodes.map((node) => this.createLayoutNode(node, options));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 创建节点布局数据 */
|
|
88
|
+
private createLayoutNode(node: WorkflowNodeEntity, options: LayoutOptions): LayoutNode {
|
|
89
|
+
const blocks = node.blocks.filter((blockNode) =>
|
|
90
|
+
options.filterNode ? options.filterNode?.({ node: blockNode, parent: node.parent }) : true
|
|
91
|
+
);
|
|
92
|
+
const edges = this.getNodesAllLines(blocks).filter((edge) =>
|
|
93
|
+
options.filterLine ? options.filterLine?.({ line: edge }) : true
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// 创建子布局节点
|
|
97
|
+
const layoutNodes = this.createLayoutNodes(blocks, options);
|
|
98
|
+
const layoutEdges = this.createLayoutEdges(edges);
|
|
99
|
+
|
|
100
|
+
const { bounds, padding } = node.transform;
|
|
101
|
+
const { width, height, center } = bounds;
|
|
102
|
+
const { x, y } = center;
|
|
103
|
+
const layoutNode: LayoutNode = {
|
|
104
|
+
id: node.id,
|
|
105
|
+
entity: node,
|
|
106
|
+
index: '', // 初始化时,index 未计算
|
|
107
|
+
rank: -1, // 初始化时,节点还未布局,层级为-1
|
|
108
|
+
order: -1, // 初始化时,节点还未布局,顺序为-1
|
|
109
|
+
position: { x, y },
|
|
110
|
+
offset: { x: 0, y: 0 },
|
|
111
|
+
padding,
|
|
112
|
+
size: { width, height },
|
|
113
|
+
layoutNodes,
|
|
114
|
+
layoutEdges,
|
|
115
|
+
};
|
|
116
|
+
return layoutNode;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private createLayoutEdges(edges: WorkflowLineEntity[]): LayoutEdge[] {
|
|
120
|
+
const layoutEdges = edges
|
|
121
|
+
.map((edge) => this.createLayoutEdge(edge))
|
|
122
|
+
.filter(Boolean) as LayoutEdge[];
|
|
123
|
+
return layoutEdges;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 创建线条布局数据 */
|
|
127
|
+
private createLayoutEdge(edge: WorkflowLineEntity): LayoutEdge | undefined {
|
|
128
|
+
const { from, to } = edge.info;
|
|
129
|
+
if (!from || !to) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const layoutEdge: LayoutEdge = {
|
|
133
|
+
id: edge.id,
|
|
134
|
+
entity: edge,
|
|
135
|
+
from,
|
|
136
|
+
to,
|
|
137
|
+
fromIndex: '', // 初始化时,index 未计算
|
|
138
|
+
toIndex: '', // 初始化时,index 未计算
|
|
139
|
+
name: edge.id,
|
|
140
|
+
};
|
|
141
|
+
return layoutEdge;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private getNodesAllLines(nodes: WorkflowNodeEntity[]): WorkflowLineEntity[] {
|
|
145
|
+
const lines = nodes
|
|
146
|
+
.map((node) => {
|
|
147
|
+
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
|
|
148
|
+
const outputLines = linesData.outputLines.filter(Boolean);
|
|
149
|
+
const inputLines = linesData.inputLines.filter(Boolean);
|
|
150
|
+
return [...outputLines, ...inputLines];
|
|
151
|
+
})
|
|
152
|
+
.flat();
|
|
153
|
+
|
|
154
|
+
return lines;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private getLayoutNodeRect(layoutNode: LayoutNode): Rectangle {
|
|
158
|
+
const rects = layoutNode.layoutNodes.map((node) => this.layoutNodeRect(node));
|
|
159
|
+
const rect = Rectangle.enlarge(rects);
|
|
160
|
+
const { padding } = layoutNode;
|
|
161
|
+
const width = rect.width + padding.left + padding.right;
|
|
162
|
+
const height = rect.height + padding.top + padding.bottom;
|
|
163
|
+
const x = rect.x - padding.left;
|
|
164
|
+
const y = rect.y - padding.top;
|
|
165
|
+
return new Rectangle(x, y, width, height);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private layoutNodeRect(layoutNode: LayoutNode): Rectangle {
|
|
169
|
+
const { width, height } = layoutNode.size;
|
|
170
|
+
const x = layoutNode.position.x - width / 2;
|
|
171
|
+
const y = layoutNode.position.y - height / 2;
|
|
172
|
+
return new Rectangle(x, y, width, height);
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/type.ts
ADDED