diff --git a/packages/layout-wasm/README.md b/packages/layout-wasm/README.md
index 9e6eca17..946e53ac 100644
--- a/packages/layout-wasm/README.md
+++ b/packages/layout-wasm/README.md
@@ -15,6 +15,7 @@ Now we support the following layouts:
- [Fruchterman](#Fruchterman)
- [Force](#Force)
- [Dagre](#Dagre)
+- [Graphviz](#Graphviz)
## Usage
@@ -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`.
+### 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
diff --git a/packages/layout-wasm/package.json b/packages/layout-wasm/package.json
index d7492b3c..0a195e3d 100644
--- a/packages/layout-wasm/package.json
+++ b/packages/layout-wasm/package.json
@@ -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",
diff --git a/packages/layout-wasm/src/graphviz/dot.ts b/packages/layout-wasm/src/graphviz/dot.ts
new file mode 100644
index 00000000..82b08613
--- /dev/null
+++ b/packages/layout-wasm/src/graphviz/dot.ts
@@ -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) {
+ 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;
+ }
+}
diff --git a/packages/layout-wasm/src/graphviz/edge.ts b/packages/layout-wasm/src/graphviz/edge.ts
new file mode 100644
index 00000000..98fb411c
--- /dev/null
+++ b/packages/layout-wasm/src/graphviz/edge.ts
@@ -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',
+ };
+ }
+}
diff --git a/packages/layout-wasm/src/graphviz/graph.ts b/packages/layout-wasm/src/graphviz/graph.ts
new file mode 100644
index 00000000..23f4e9cb
--- /dev/null
+++ b/packages/layout-wasm/src/graphviz/graph.ts
@@ -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),
+ };
+ }
+}
diff --git a/packages/layout-wasm/src/graphviz/index.ts b/packages/layout-wasm/src/graphviz/index.ts
new file mode 100644
index 00000000..32936162
--- /dev/null
+++ b/packages/layout-wasm/src/graphviz/index.ts
@@ -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 {
+ static defaultOptions: Partial = {};
+
+ public gp: Promise = Graphviz.load();
+
+ public id = 'graphvizDotWASM';
+
+ public options: Partial = {
+ preLayout: true,
+ };
+
+ constructor(options: Partial) {
+ Object.assign(this.options, GraphvizDotLayout.defaultOptions, options);
+ }
+
+ async execute(graph: Graph, options?: GraphvizDotLayoutOptions): Promise {
+ return this.generateLayout(graph, {
+ ...this.options,
+ ...options,
+ });
+ }
+
+ async assign(graph: Graph, options?: GraphvizDotLayoutOptions): Promise {
+ 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,
+ edges: ReturnType,
+ 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[],
+ };
+ }
+}
diff --git a/packages/layout-wasm/src/graphviz/mapping.ts b/packages/layout-wasm/src/graphviz/mapping.ts
new file mode 100644
index 00000000..976ee323
--- /dev/null
+++ b/packages/layout-wasm/src/graphviz/mapping.ts
@@ -0,0 +1,123 @@
+import { Edge } from './edge';
+import { Node } from './node';
+import { type TNodesEdgesMap } from './types';
+
+// 用于匹配 antv 数据渲染
+export class Mapping {
+ outputMap: TNodesEdgesMap;
+ graphSize: {
+ width: number;
+ height: number;
+ };
+ constructor(outputString: string, outputMap: TNodesEdgesMap) {
+ this.outputMap = [];
+ this.graphSize = { width: 0, height: 0 };
+ this.setLayouts(outputString, outputMap);
+ }
+ public getLayoutMap(): Mapping['outputMap'] {
+ return this.outputMap;
+ }
+ public getGraphSize(): Mapping['graphSize'] {
+ return this.graphSize;
+ }
+ private setLayouts(outputString: string, outputMap: TNodesEdgesMap) {
+ const svgDoc = new DOMParser().parseFromString(outputString, 'image/svg+xml');
+ const nodeElements = svgDoc.querySelectorAll('.node');
+ const edgeElements = svgDoc.querySelectorAll('.edge');
+
+ outputMap.forEach((item, idx) => {
+ if (item instanceof Node) {
+ this.setNodeLayout(item, nodeElements, idx);
+ } else if (item instanceof Edge) {
+ this.setEdgeLayout(item, edgeElements, idx);
+ }
+ });
+ this.outputMap = outputMap;
+ this.calculateGraphSize(svgDoc);
+ }
+ private setNodeLayout(node: Node, nodeElements: NodeListOf, idx: number) {
+ const targetEle = this.findElementByTitle(nodeElements, idx.toString());
+ if (!targetEle) {
+ return;
+ }
+ const ellipseEle = targetEle.querySelector('ellipse');
+ // 起止节点
+ if (ellipseEle) {
+ node.setLayout({
+ position: {
+ x: parseFloat(ellipseEle.getAttribute('cx') ?? '0'),
+ y: parseFloat(ellipseEle.getAttribute('cy') ?? '0'),
+ },
+ size: {
+ width: parseFloat(ellipseEle.getAttribute('rx') ?? '0'),
+ height: parseFloat(ellipseEle.getAttribute('ry') ?? '0'),
+ },
+ });
+ } else {
+ const polygonEle = targetEle.querySelector('polygon');
+ node.setLayout(this.getPolygonAttributes(polygonEle));
+ }
+ }
+ private setEdgeLayout(edge: Edge, edgeElements: NodeListOf, idx: number) {
+ const targetEle = this.findElementByClassName(edgeElements, `edge_${idx}`);
+ if (!targetEle) {
+ return;
+ }
+ const pathEle = targetEle.querySelector('path');
+ const textEle = targetEle.querySelector('text');
+ const labelPosition = {
+ x: parseFloat(textEle?.getAttribute('x') ?? '0'),
+ y: parseFloat(textEle?.getAttribute('y') ?? '0'),
+ };
+ edge.setLayout(pathEle?.getAttribute('d') ?? '', labelPosition);
+ }
+ private calculateGraphSize(svgDoc: Document) {
+ const svgElement = svgDoc.getElementsByTagName('svg')[0];
+
+ // 获取宽度(添加45px的额外空间)
+ const rawWidth = svgElement.getAttribute('width') || '0';
+ const width = parseFloat(rawWidth) + 45;
+
+ // 获取高度
+ const rawHeight = svgElement.getAttribute('height') || '0';
+ const height = parseFloat(rawHeight);
+
+ this.graphSize = { width, height };
+ }
+ private findElementByTitle(elements: NodeListOf, title: string) {
+ return Array.from(elements).find((e) => {
+ const titleEle = e.querySelector('title');
+ return titleEle && titleEle.textContent === title;
+ });
+ }
+ private findElementByClassName(elements: NodeListOf, className: string) {
+ return Array.from(elements).find((e) => e.classList.contains(className));
+ }
+ private getPolygonAttributes(polygon: SVGPolygonElement | null) {
+ if (!polygon) return {};
+ const { minX, maxX, minY, maxY } = Array.from(polygon.points).reduce(
+ (acc, cur) => ({
+ minX: Math.min(acc.minX, cur.x),
+ maxX: Math.max(acc.maxX, cur.x),
+ minY: Math.min(acc.minY, cur.y),
+ maxY: Math.max(acc.maxY, cur.y),
+ }),
+ {
+ minX: Number.POSITIVE_INFINITY,
+ maxX: Number.NEGATIVE_INFINITY,
+ minY: Number.POSITIVE_INFINITY,
+ maxY: Number.NEGATIVE_INFINITY,
+ }
+ );
+ return {
+ position: {
+ x: minX + (maxX - minX) / 2,
+ y: minY + (maxY - minY) / 2,
+ },
+ size: {
+ width: maxX - minX,
+ height: maxY - minY,
+ },
+ };
+ }
+}
diff --git a/packages/layout-wasm/src/graphviz/node.ts b/packages/layout-wasm/src/graphviz/node.ts
new file mode 100644
index 00000000..4e06ba93
--- /dev/null
+++ b/packages/layout-wasm/src/graphviz/node.ts
@@ -0,0 +1,26 @@
+import { px2Inch } from '../util';
+import { GraphvizDotLayoutOptions, type TProcessData } from './types';
+
+export class Node {
+ options: GraphvizDotLayoutOptions;
+ node: TProcessData['nodes'][0];
+ attrs: {
+ width?: number;
+ height?: number;
+ shape?: 'box' | 'circle';
+ } = {};
+ layout: {
+ position?: { x: number; y: number };
+ size?: { width: number; height: number };
+ } = {};
+ constructor(n: TProcessData['nodes'][0], options: GraphvizDotLayoutOptions) {
+ this.node = n;
+ this.options = options;
+ this.attrs.shape = 'box';
+ this.attrs.width = parseFloat(px2Inch(this.node.data.width).toFixed(2));
+ this.attrs.height = parseFloat(px2Inch(this.node.data.height).toFixed(2));
+ }
+ public setLayout(layout: Node['layout']): void {
+ this.layout = layout;
+ }
+}
diff --git a/packages/layout-wasm/src/graphviz/types.ts b/packages/layout-wasm/src/graphviz/types.ts
new file mode 100644
index 00000000..9da46198
--- /dev/null
+++ b/packages/layout-wasm/src/graphviz/types.ts
@@ -0,0 +1,36 @@
+import { NodeData, EdgeData, Node as AntvNode, Size } from '@antv/layout';
+
+import { type Edge } from './edge';
+import { type Node } from './node';
+
+export interface IGraphvizAttrs {
+ ranksep?: number;
+ nodesep?: number;
+ nclimit?: number;
+ mclimit?: number;
+ splines?: boolean;
+ rankdir?: 'LR' | 'TB';
+}
+
+export interface IObj {
+ [key: string]: unknown;
+}
+
+export type TProcessData = {
+ nodes: NodeData[];
+ edges: EdgeData[];
+};
+
+export interface GraphvizDotLayoutOptions extends IGraphvizAttrs {
+ nodeSize?: Size | ((node: AntvNode) => Size);
+ preLayout?: boolean;
+ iterations?: number;
+ getWeight?: (edge: EdgeData) => number;
+}
+
+export type TNodesEdgesMap = (Node | Edge)[];
+
+export type TIdsMap = {
+ node: Node;
+ index: number;
+}[];
\ No newline at end of file
diff --git a/packages/layout-wasm/src/index.ts b/packages/layout-wasm/src/index.ts
index 75cc2297..c8aa2d71 100644
--- a/packages/layout-wasm/src/index.ts
+++ b/packages/layout-wasm/src/index.ts
@@ -1,4 +1,5 @@
import { AntVDagreLayout } from './dagre';
+import { GraphvizDotLayout } from './graphviz';
import { ForceLayout } from './force';
import { ForceAtlas2Layout } from './forceatlas2';
import { FruchtermanLayout } from './fruchterman';
@@ -13,4 +14,5 @@ export {
ForceAtlas2Layout,
ForceLayout,
AntVDagreLayout,
+ GraphvizDotLayout
};
diff --git a/packages/layout-wasm/src/util.ts b/packages/layout-wasm/src/util.ts
index 85bdc93b..5b6694b0 100644
--- a/packages/layout-wasm/src/util.ts
+++ b/packages/layout-wasm/src/util.ts
@@ -56,3 +56,24 @@ export function distanceThresholdMode2Index(
max: 2,
}[mode];
}
+
+
+export function parsePathToPoints(pathStr: string): { x: number; y: number }[] {
+ // 步骤1:提取所有数字(包括负号和小数点)
+ const numbers = pathStr.match(/[-+]?\d+\.\d+|\d+\.\d+|[-+]?\d+/g);
+ const points = [];
+
+ // 步骤2:每两个数字组成一个点对象
+ for (let i = 0; i < numbers.length; i += 2) {
+ if (i + 1 >= numbers.length) break; // 防止最后一个孤立的数字
+ points.push({
+ x: parseFloat(numbers[i]),
+ y: parseFloat(numbers[i + 1]),
+ });
+ }
+ return points;
+}
+
+export function px2Inch(px: number): number {
+ return parseFloat((px / 72).toFixed(2));
+}
\ No newline at end of file
diff --git a/packages/layout/src/util/index.ts b/packages/layout/src/util/index.ts
index f697b75f..62e9ffbb 100644
--- a/packages/layout/src/util/index.ts
+++ b/packages/layout/src/util/index.ts
@@ -2,3 +2,4 @@ export * from "./array";
export * from "./math";
export * from "./object";
export * from "./function";
+export * from "./size";