Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/layout-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Now we support the following layouts:
- [Fruchterman](#Fruchterman)
- [Force](#Force)
- [Dagre](#Dagre)
- [Graphviz](#Graphviz)

## Usage

Expand Down Expand Up @@ -174,6 +175,13 @@ LayoutOptions:
- `nodesep` **number** The separation between nodes with unit px. When rankdir is 'TB' or 'BT', nodesep represents the horizontal separations between nodes; When rankdir is 'LR' or 'RL', nodesep represents the vertical separations between nodes. Defaults to `50`.
- `ranksep` **number** The separations between adjacent levels with unit px. When rankdir is 'TB' or 'BT', ranksep represents the vertical separations between adjacent levels; when rankdir is 'LR' or 'RL', rankdir represents the horizontal separations between adjacent levels. Defaults to `50`.

### <a id='Graphviz' />Graphviz
- `rankdir` **'TB' | 'BT' | 'LR' | 'RL'** The layout direction, defaults to `'TB'`.
- `nodesep` **number** The separation between nodes with unit px. When rankdir is 'TB' or 'BT', nodesep represents the horizontal separations between nodes; When rankdir is 'LR' or 'RL', nodesep represents the vertical separations between nodes. Defaults to `50`.
- `ranksep` **number** The separations between adjacent levels with unit px. When rankdir is 'TB' or 'BT', ranksep represents the vertical separations between adjacent levels; when rankdir is 'LR' or 'RL', rankdir represents the horizontal separations between adjacent levels. Defaults to `50`.
- `iterations` **number** The number of iterations(`nclimit` & `mclimit`). Defaults to `undefined` which means unset.
- `getWeight` **(edge: EdgeData) => number** The weight of the edge. Defaults to `undefined` which means unset.


## Benchmarks

Expand Down
3 changes: 2 additions & 1 deletion packages/layout-wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"@antv/util": "^3.3.2",
"comlink": "^4.3.1",
"wasm-feature-detect": "^1.2.10",
"tslib": "^2.5.0"
"tslib": "^2.5.0",
"@hpcc-js/wasm-graphviz": "1.7.0"
},
"devDependencies": {
"ts-loader": "^7.0.3",
Expand Down
103 changes: 103 additions & 0 deletions packages/layout-wasm/src/graphviz/dot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { type Graph } from './graph';
import { type Node } from './node';
import { type TIdsMap, type TNodesEdgesMap } from './types';

// 通过 graph 对象构建 dot lang string
export class Dot {
graph: Graph;
nodesEdgesMap: TNodesEdgesMap = [];
idsMap: TIdsMap = [];
output: {
outputString: string;
outputMap: TNodesEdgesMap;
} | undefined;
constructor(graph: Graph) {
this.graph = graph;
this.convertGraph2Dot();
}
public getOutput(): Dot['output'] {
return this.output;
}
private convertGraph2Dot() {
// init dot
let dot = `digraph g {`;
dot += this.initializeGraphOrientation();
dot += `graph [${this.attributesToString(this.graph.attrs)}];`;

dot += this.writeNodes(this.graph.nodes, this.nodesEdgesMap, this.idsMap);
dot += this.writeEdges(this.graph, this.nodesEdgesMap, this.idsMap);
dot += '}';
this.output = {
outputString: dot,
outputMap: this.nodesEdgesMap,
};
}
private initializeGraphOrientation() {
return this.graph.leftToRight ? ' rankdir=LR ' : ' ';
}
private writeNodes(nodes: Graph['nodes'], nodesEdgesMap: TNodesEdgesMap, idsMap: TIdsMap) {
return nodes
.map((node, index) => {
const ret = `${this.createNode(index, node)}`;
this.addToMaps(node, nodesEdgesMap, idsMap);
return ret;
})
.join('');
}
private writeEdges(graph: Graph, nodesEdgesMap: TNodesEdgesMap, idsMap: TIdsMap) {
return graph.edges
.map((edge) => {
edge.attrs.class = `edge_${nodesEdgesMap.length}`;
nodesEdgesMap.push(edge);
return `${this.findIndex(
idsMap,
graph.nodes.find((n) => n.node.id === edge.source)
).toString()} -> ${this.findIndex(
idsMap,
graph.nodes.find((n) => n.node.id === edge.target)
).toString()} [ ${this.attributesToString(edge.attrs)} ];`;
})
.join('');
}
private attributesToString(attrs: Record<string, any>) {
return Object.entries(attrs)
.map(([key, val]) => `${key}="${val}"`)
.join(', ');
}
private createNode(index: number, node: Node) {
let ret = '';
// todo: 是否需要明确 rank=source
ret += `${index} [ ${this.attributesToString(node.attrs)} ];`;
// todo: 是否需要明确 rank=sink
return ret;
}
private addToMaps(node: Node, nodesEdgesMap: TNodesEdgesMap, idsMap: TIdsMap) {
idsMap.push({
node,
index: nodesEdgesMap.length,
});
nodesEdgesMap.push(node);
}
private findIndex(idsMap: TIdsMap, node?: Node) {
// 1. 如果传入的节点不存在,直接返回-1
if (!node) {
return -1;
}

// 2. 在idsMap数组中查找与当前节点匹配的项
const foundItem = idsMap.find(item => item.node === node);

// 3. 如果未找到匹配项,返回-1
if (!foundItem) {
return -1;
}

// 4. 如果找到项的index值为null/undefined,返回-1
if (foundItem.index === null) {
return -1;
}

// 5. 返回有效的index值
return foundItem.index;
}
}
43 changes: 43 additions & 0 deletions packages/layout-wasm/src/graphviz/edge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { px2Inch } from '../util';
import { GraphvizDotLayoutOptions, type TProcessData } from './types';

export class Edge {
edge: TProcessData['edges'][0];
source: string;
target: string;
attrs: {
arrowsize?: number;
tailclip?: boolean;
fontsize?: number;
label?: string;
weight?: number;
class?: string;
} = {};
layout: {
path?: any;
labelPosition?: any;
} = {};
constructor(e: TProcessData['edges'][0], options: GraphvizDotLayoutOptions) {
this.edge = e;
this.source = e.source;
this.target = e.target;
this.attrs = {
...this.getDefaultAttrs(),
weight: options.getWeight?.(this.edge),
};
}
public setLayout(path: any, labelPosition: any): void {
this.layout = {
path,
labelPosition,
};
}
private getDefaultAttrs(): Edge['attrs'] {
return {
arrowsize: px2Inch(36),
tailclip: false,
fontsize: 12,
label: 'MMM',
};
}
}
51 changes: 51 additions & 0 deletions packages/layout-wasm/src/graphviz/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@

import { px2Inch } from '../util';
import { Edge } from './edge';
import { Node } from './node';
import { type TProcessData, type GraphvizDotLayoutOptions, type IGraphvizAttrs } from './types';

// 通过接口数据构建内存 graph 对象
export class Graph {
processData: TProcessData;
edges: Edge[] = [];
nodes: Node[] = [];
leftToRight = false;
attrs: IGraphvizAttrs = {};

constructor(processData: TProcessData, options: GraphvizDotLayoutOptions) {
this.processData = processData;
const attributes = { ...Graph.getDefaultAttrs(), ...this.inchFyAttrs(options) };
this.initializeAttrs(attributes);
this.setLayoutAttrs(attributes);
this.nodes = processData.nodes.map((n) => new Node(n, options));
this.edges = processData.edges.map((e) => new Edge(e, options));
}

private inchFyAttrs(attrs: GraphvizDotLayoutOptions) {
return Object.entries(attrs).reduce((acc, [key, val]) => ({
...acc,
[key]: typeof val === 'number' ? px2Inch(val) : val,
}), {})
}

private initializeAttrs(attrs: GraphvizDotLayoutOptions) {
const { nodesep, nclimit, mclimit, splines, iterations } = attrs;
this.attrs.nodesep = nodesep;
// 限制节点连接调整的迭代次数为 n 次
this.attrs.nclimit = nclimit ?? iterations;
// 限制多边连接调整的迭代次数为 n 次
this.attrs.mclimit = mclimit ?? iterations;
// 用折线(直线段)而不是曲线来绘制边
this.attrs.splines = splines;
}
private setLayoutAttrs(attr: GraphvizDotLayoutOptions) {
this.attrs.ranksep = attr.ranksep;
}
static getDefaultAttrs(): GraphvizDotLayoutOptions {
return {
rankdir: 'TB',
nodesep: px2Inch(50),
ranksep: px2Inch(50),
};
}
}
112 changes: 112 additions & 0 deletions packages/layout-wasm/src/graphviz/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { type Graph, type Layout, type LayoutMapping, type EdgeData, type NodeData, parseSize } from '@antv/layout';
import { Graphviz } from '@hpcc-js/wasm-graphviz';

import { Dot } from './dot';
import { Edge } from './edge';
import { Graph as GraphvizGraph } from './graph';
import { Mapping } from './mapping';
import { Node } from './node';
import type { TProcessData, GraphvizDotLayoutOptions } from './types';
import { parsePathToPoints } from '../util';
import { isFunction } from '@antv/util';

export class GraphvizDotLayout implements Layout<GraphvizDotLayoutOptions> {
static defaultOptions: Partial<GraphvizDotLayoutOptions> = {};

public gp: Promise<Graphviz> = Graphviz.load();

public id = 'graphvizDotWASM';

public options: Partial<GraphvizDotLayoutOptions> = {
preLayout: true,
};

constructor(options: Partial<GraphvizDotLayoutOptions>) {
Object.assign(this.options, GraphvizDotLayout.defaultOptions, options);
}

async execute(graph: Graph, options?: GraphvizDotLayoutOptions): Promise<LayoutMapping> {
return this.generateLayout(graph, {
...this.options,
...options,
});
}

async assign(graph: Graph, options?: GraphvizDotLayoutOptions): Promise<void> {
await this.generateLayout(graph, { ...this.options, ...options });
}

private async generateLayout(graph: Graph, options: GraphvizDotLayoutOptions) {
const graphviz = await this.gp;

const nodes = graph.getAllNodes();
const edges = graph.getAllEdges();

const processData = this.getProcessData(nodes, edges, options);

const graphvizGraph = new GraphvizGraph(processData as any, options);
const dot = new Dot(graphvizGraph).getOutput();
// 只有 svg 中有完整布局信息位置
const dotOutputStr = graphviz.layout(dot!.outputString, 'svg', 'dot');

const _mapping = new Mapping(dotOutputStr, dot!.outputMap);

const mapping: LayoutMapping = { nodes: [], edges: [] };

_mapping.getLayoutMap().forEach((ele) => {
if (ele instanceof Node) {
mapping.nodes.push({
id: ele.node.id,
data: {
...ele.node.data,
x: ele.layout.position?.x,
y: ele.layout.position?.y,
width: ele.layout.size?.width,
height: ele.layout.size?.height,
},
});
} else if (ele instanceof Edge) {
mapping.edges.push({
id: ele.edge.id,
source: ele.edge.source,
target: ele.edge.target,
data: {
points: parsePathToPoints(ele.layout.path),
labelPosition: ele.layout.labelPosition,
weight: ele.attrs.weight,
},
});
}
});

return mapping;
}
private getProcessData(
nodes: ReturnType<Graph['getAllNodes']>,
edges: ReturnType<Graph['getAllEdges']>,
options: GraphvizDotLayoutOptions
): TProcessData {
const { nodeSize } = options;
return {
nodes: nodes.map((node) => {
const data = { ...node.data };
if (nodeSize !== undefined) {
const [width, height] = parseSize(
isFunction(nodeSize) ? nodeSize(node) : nodeSize,
);
Object.assign(data, { width, height });
}
return {
id: String(node.id),
data,
};
}) as NodeData[],
edges: edges.map((edge) => ({
id: String(edge.id),
source: String(edge.source),
target: String(edge.target),
data: edge.data,
})) as EdgeData[],
};
}
}
Loading
Loading