From 67dc8e1e6651b2b40e9716032edeeec74e4d0008 Mon Sep 17 00:00:00 2001 From: prgrmrwy Date: Sun, 6 Jul 2025 17:17:43 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20graphviz=20dot?= =?UTF-8?q?=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/layout-wasm/package.json | 6 +- packages/layout-wasm/src/graphviz/dot.ts | 98 +++++++++++++++ packages/layout-wasm/src/graphviz/edge.ts | 44 +++++++ packages/layout-wasm/src/graphviz/graph.ts | 47 +++++++ packages/layout-wasm/src/graphviz/index.ts | 112 +++++++++++++++++ packages/layout-wasm/src/graphviz/mapping.ts | 122 +++++++++++++++++++ packages/layout-wasm/src/graphviz/node.ts | 24 ++++ packages/layout-wasm/src/graphviz/types.ts | 72 +++++++++++ packages/layout-wasm/src/index.ts | 2 + packages/layout-wasm/src/util.ts | 21 ++++ packages/layout/src/util/index.ts | 1 + 11 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 packages/layout-wasm/src/graphviz/dot.ts create mode 100644 packages/layout-wasm/src/graphviz/edge.ts create mode 100644 packages/layout-wasm/src/graphviz/graph.ts create mode 100644 packages/layout-wasm/src/graphviz/index.ts create mode 100644 packages/layout-wasm/src/graphviz/mapping.ts create mode 100644 packages/layout-wasm/src/graphviz/node.ts create mode 100644 packages/layout-wasm/src/graphviz/types.ts diff --git a/packages/layout-wasm/package.json b/packages/layout-wasm/package.json index d7492b3c..38856c83 100644 --- a/packages/layout-wasm/package.json +++ b/packages/layout-wasm/package.json @@ -32,14 +32,16 @@ "build:esm": "tsc", "build:umd": "webpack --config webpack.config.js --mode production", "build": "npm run clean && npm run build:wasm && npm run build:esm && npm run build:umd", - "clean": "rimraf dist pkg pkg-parallel pkg-node" + "clean": "rimraf dist pkg pkg-parallel pkg-node", + "test": "jest" }, "dependencies": { "@antv/layout": "workspace:*", "@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..bce02106 --- /dev/null +++ b/packages/layout-wasm/src/graphviz/dot.ts @@ -0,0 +1,98 @@ +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) => { + // eslint-disable-next-line no-param-reassign + 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 == null) 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..e2be5198 --- /dev/null +++ b/packages/layout-wasm/src/graphviz/edge.ts @@ -0,0 +1,44 @@ +import { px2Inch } from '../util'; +import { 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: { getWeight?: (e: TProcessData['edges'][0]) => number }) { + this.edge = e; + this.source = e.source; + this.target = e.target; + this.attrs = { + ...this.getDefaultAttrs(), + weight: options.getWeight?.(this.edge), + }; + } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + 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..f86905e0 --- /dev/null +++ b/packages/layout-wasm/src/graphviz/graph.ts @@ -0,0 +1,47 @@ + +import { px2Inch } from '../util'; +import { Edge } from './edge'; +import { Node } from './node'; +import { type TProcessData, type IAttrs, type IGraphvizAttrs } from './types'; + +// 通过接口数据构建内存 graph 对象 +export class Graph { + processData: TProcessData; + edges: Edge[] = []; + nodes: Node[] = []; + leftToRight = false; + attrs: IGraphvizAttrs = {}; + + constructor(processData: TProcessData, attrs: IAttrs) { + this.processData = processData; + const { getWeight } = attrs; + const attributes = { ...this.getDefaultAttrs(), ...attrs }; + this.initializeAttrs(attributes.nodeSpacing!, attributes.simpleMode); + this.setLayoutAttrs(attributes.rankSpacing!); + this.nodes = processData.nodes.map((n) => new Node(n)); + this.edges = processData.edges.map((e) => new Edge(e, { getWeight })); + } + + private initializeAttrs(nodeSpacing: number, simpleMode = false) { + this.attrs.nodesep = nodeSpacing; + if (simpleMode) { + // 限制节点连接调整的迭代次数为1次 + this.attrs.nclimit = 1; + // 限制多边连接调整的迭代次数为1次 + this.attrs.mclimit = 1; + // 用折线(直线段)而不是曲线来绘制边 + this.attrs.splines = false; + } + } + private setLayoutAttrs(rankSpacing: number) { + this.attrs.ranksep = rankSpacing; + } + private getDefaultAttrs(): IAttrs { + return { + leftToRight: false, + nodeSpacing: px2Inch(56), + rankSpacing: px2Inch(60), + simpleMode: false, + }; + } +} diff --git a/packages/layout-wasm/src/graphviz/index.ts b/packages/layout-wasm/src/graphviz/index.ts new file mode 100644 index 00000000..dc22dd3b --- /dev/null +++ b/packages/layout-wasm/src/graphviz/index.ts @@ -0,0 +1,112 @@ +import { Graph, Layout, LayoutMapping, EdgeData, 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(false, graph, { + ...this.options, + ...options, + }); + } + + async assign(graph: Graph, options?: GraphvizDotLayoutOptions): Promise { + await this.generateLayout(true, graph, { ...this.options, ...options }); + } + + private async generateLayout(assign: boolean, 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..0ca57792 --- /dev/null +++ b/packages/layout-wasm/src/graphviz/mapping.ts @@ -0,0 +1,122 @@ +import { Edge } from './edge'; +import { Node } from './node'; +import { type TNodesEdgesMap } from './types'; + +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..9f9da83f --- /dev/null +++ b/packages/layout-wasm/src/graphviz/node.ts @@ -0,0 +1,24 @@ +import { px2Inch } from '../util'; +import { type TProcessData } from './types'; + +export class Node { + 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]) { + this.node = n; + 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..c2b7cca8 --- /dev/null +++ b/packages/layout-wasm/src/graphviz/types.ts @@ -0,0 +1,72 @@ +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; +} +export interface IAttrs { + // layout: string; + leftToRight?: boolean; + nodeSpacing?: number; + rankSpacing?: number; + simpleMode?: boolean; + getWeight?: (edge: EdgeData) => number; +} + +export interface IObj { + [key: string]: unknown; +} + +// 这个奇怪的结构来自 antv graph getAllNodes 和 getAllEdges 方法 +export type TProcessData = { + nodes: NodeData[]; + edges: EdgeData[]; +}; + +export interface GraphvizDotLayoutOptions extends IAttrs { + nodeSize?: Size | ((node: AntvNode) => Size); + preLayout?: boolean; + getWeight?: (edge: EdgeData) => number; +} + +export type TNodesEdgesMap = (Node | Edge)[]; + +export type TIdsMap = { + node: Node; + index: number; +}[]; + +export interface IDotJson { + bb: string; // x1,y1,x2,y2 + name: string; + randir: string; + nodesep: string; + ranksep: string; + xdotversion: string; + objects: { + _gvid: number; + width: string; // number + height: string; // number + label: string; + shape: 'box' | 'circle'; + pos: string; // x, y + fixedsize: string; // boolean; + }[]; + edges: { + _gvid: number; + label: string; + pos: string; // path + head: number; + tail: number; + lp: string; // x, y labelposition? + weight: string; // number + arrowsize: string; // number + tailclip: string; // boolean + }[]; +} 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"; From 3c71af17b9bdad382348c9631d0c087031176a38 Mon Sep 17 00:00:00 2001 From: prgrmrwy Date: Sun, 6 Jul 2025 17:18:18 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=20jest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/layout-wasm/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/layout-wasm/package.json b/packages/layout-wasm/package.json index 38856c83..0a195e3d 100644 --- a/packages/layout-wasm/package.json +++ b/packages/layout-wasm/package.json @@ -32,8 +32,7 @@ "build:esm": "tsc", "build:umd": "webpack --config webpack.config.js --mode production", "build": "npm run clean && npm run build:wasm && npm run build:esm && npm run build:umd", - "clean": "rimraf dist pkg pkg-parallel pkg-node", - "test": "jest" + "clean": "rimraf dist pkg pkg-parallel pkg-node" }, "dependencies": { "@antv/layout": "workspace:*", From 366605ce9f8a196c959f40daf394096a70a0fefe Mon Sep 17 00:00:00 2001 From: prgrmrwy Date: Sun, 6 Jul 2025 17:55:08 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=20attrs=20?= =?UTF-8?q?=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/layout-wasm/src/graphviz/edge.ts | 4 +- packages/layout-wasm/src/graphviz/graph.ts | 54 ++++++++++++---------- packages/layout-wasm/src/graphviz/index.ts | 8 ++-- packages/layout-wasm/src/graphviz/node.ts | 6 ++- packages/layout-wasm/src/graphviz/types.ts | 12 ++--- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/layout-wasm/src/graphviz/edge.ts b/packages/layout-wasm/src/graphviz/edge.ts index e2be5198..4f1b2fad 100644 --- a/packages/layout-wasm/src/graphviz/edge.ts +++ b/packages/layout-wasm/src/graphviz/edge.ts @@ -1,5 +1,5 @@ import { px2Inch } from '../util'; -import { type TProcessData } from './types'; +import { GraphvizDotLayoutOptions, type TProcessData } from './types'; export class Edge { edge: TProcessData['edges'][0]; @@ -17,7 +17,7 @@ export class Edge { path?: any; labelPosition?: any; } = {}; - constructor(e: TProcessData['edges'][0], options: { getWeight?: (e: TProcessData['edges'][0]) => number }) { + constructor(e: TProcessData['edges'][0], options: GraphvizDotLayoutOptions) { this.edge = e; this.source = e.source; this.target = e.target; diff --git a/packages/layout-wasm/src/graphviz/graph.ts b/packages/layout-wasm/src/graphviz/graph.ts index f86905e0..38eb9e72 100644 --- a/packages/layout-wasm/src/graphviz/graph.ts +++ b/packages/layout-wasm/src/graphviz/graph.ts @@ -2,7 +2,7 @@ import { px2Inch } from '../util'; import { Edge } from './edge'; import { Node } from './node'; -import { type TProcessData, type IAttrs, type IGraphvizAttrs } from './types'; +import { type TProcessData, type GraphvizDotLayoutOptions, type IGraphvizAttrs } from './types'; // 通过接口数据构建内存 graph 对象 export class Graph { @@ -12,36 +12,40 @@ export class Graph { leftToRight = false; attrs: IGraphvizAttrs = {}; - constructor(processData: TProcessData, attrs: IAttrs) { + constructor(processData: TProcessData, options: GraphvizDotLayoutOptions) { this.processData = processData; - const { getWeight } = attrs; - const attributes = { ...this.getDefaultAttrs(), ...attrs }; - this.initializeAttrs(attributes.nodeSpacing!, attributes.simpleMode); - this.setLayoutAttrs(attributes.rankSpacing!); - this.nodes = processData.nodes.map((n) => new Node(n)); - this.edges = processData.edges.map((e) => new Edge(e, { getWeight })); + 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 initializeAttrs(nodeSpacing: number, simpleMode = false) { - this.attrs.nodesep = nodeSpacing; - if (simpleMode) { - // 限制节点连接调整的迭代次数为1次 - this.attrs.nclimit = 1; - // 限制多边连接调整的迭代次数为1次 - this.attrs.mclimit = 1; - // 用折线(直线段)而不是曲线来绘制边 - this.attrs.splines = false; - } + private inchFyAttrs(attrs: GraphvizDotLayoutOptions) { + return Object.entries(attrs).reduce((acc, [key, val]) => ({ + ...acc, + [key]: typeof val === 'number' ? px2Inch(val) : val, + }), {}) } - private setLayoutAttrs(rankSpacing: number) { - this.attrs.ranksep = rankSpacing; + + 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; } - private getDefaultAttrs(): IAttrs { + static getDefaultAttrs(): GraphvizDotLayoutOptions { return { - leftToRight: false, - nodeSpacing: px2Inch(56), - rankSpacing: px2Inch(60), - simpleMode: false, + rankdir: 'TB', + nodesep: px2Inch(56), + ranksep: px2Inch(60), }; } } diff --git a/packages/layout-wasm/src/graphviz/index.ts b/packages/layout-wasm/src/graphviz/index.ts index dc22dd3b..32936162 100644 --- a/packages/layout-wasm/src/graphviz/index.ts +++ b/packages/layout-wasm/src/graphviz/index.ts @@ -1,4 +1,4 @@ -import { Graph, Layout, LayoutMapping, EdgeData, NodeData, parseSize } from '@antv/layout'; +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'; @@ -26,17 +26,17 @@ export class GraphvizDotLayout implements Layout { } async execute(graph: Graph, options?: GraphvizDotLayoutOptions): Promise { - return this.generateLayout(false, graph, { + return this.generateLayout(graph, { ...this.options, ...options, }); } async assign(graph: Graph, options?: GraphvizDotLayoutOptions): Promise { - await this.generateLayout(true, graph, { ...this.options, ...options }); + await this.generateLayout(graph, { ...this.options, ...options }); } - private async generateLayout(assign: boolean, graph: Graph, options: GraphvizDotLayoutOptions) { + private async generateLayout(graph: Graph, options: GraphvizDotLayoutOptions) { const graphviz = await this.gp; const nodes = graph.getAllNodes(); diff --git a/packages/layout-wasm/src/graphviz/node.ts b/packages/layout-wasm/src/graphviz/node.ts index 9f9da83f..4e06ba93 100644 --- a/packages/layout-wasm/src/graphviz/node.ts +++ b/packages/layout-wasm/src/graphviz/node.ts @@ -1,7 +1,8 @@ import { px2Inch } from '../util'; -import { type TProcessData } from './types'; +import { GraphvizDotLayoutOptions, type TProcessData } from './types'; export class Node { + options: GraphvizDotLayoutOptions; node: TProcessData['nodes'][0]; attrs: { width?: number; @@ -12,8 +13,9 @@ export class Node { position?: { x: number; y: number }; size?: { width: number; height: number }; } = {}; - constructor(n: TProcessData['nodes'][0]) { + 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)); diff --git a/packages/layout-wasm/src/graphviz/types.ts b/packages/layout-wasm/src/graphviz/types.ts index c2b7cca8..0a911721 100644 --- a/packages/layout-wasm/src/graphviz/types.ts +++ b/packages/layout-wasm/src/graphviz/types.ts @@ -9,14 +9,7 @@ export interface IGraphvizAttrs { nclimit?: number; mclimit?: number; splines?: boolean; -} -export interface IAttrs { - // layout: string; - leftToRight?: boolean; - nodeSpacing?: number; - rankSpacing?: number; - simpleMode?: boolean; - getWeight?: (edge: EdgeData) => number; + rankdir?: 'LR' | 'TB'; } export interface IObj { @@ -29,9 +22,10 @@ export type TProcessData = { edges: EdgeData[]; }; -export interface GraphvizDotLayoutOptions extends IAttrs { +export interface GraphvizDotLayoutOptions extends IGraphvizAttrs { nodeSize?: Size | ((node: AntvNode) => Size); preLayout?: boolean; + iterations?: number; getWeight?: (edge: EdgeData) => number; } From cc179e9c63f8cb5f0261b2fe767cd31637e8432d Mon Sep 17 00:00:00 2001 From: prgrmrwy Date: Sun, 6 Jul 2025 18:01:25 +0800 Subject: [PATCH 4/6] feat: add readme --- packages/layout-wasm/README.md | 8 ++++++++ packages/layout-wasm/src/graphviz/graph.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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/src/graphviz/graph.ts b/packages/layout-wasm/src/graphviz/graph.ts index 38eb9e72..23f4e9cb 100644 --- a/packages/layout-wasm/src/graphviz/graph.ts +++ b/packages/layout-wasm/src/graphviz/graph.ts @@ -44,8 +44,8 @@ export class Graph { static getDefaultAttrs(): GraphvizDotLayoutOptions { return { rankdir: 'TB', - nodesep: px2Inch(56), - ranksep: px2Inch(60), + nodesep: px2Inch(50), + ranksep: px2Inch(50), }; } } From ce81c4a3deff94737197474b3e1a7cf79208cb44 Mon Sep 17 00:00:00 2001 From: prgrmrwy Date: Sun, 6 Jul 2025 18:13:10 +0800 Subject: [PATCH 5/6] fix: ts err --- packages/layout-wasm/src/graphviz/dot.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/layout-wasm/src/graphviz/dot.ts b/packages/layout-wasm/src/graphviz/dot.ts index bce02106..a67eb316 100644 --- a/packages/layout-wasm/src/graphviz/dot.ts +++ b/packages/layout-wasm/src/graphviz/dot.ts @@ -81,16 +81,22 @@ export class Dot { } private findIndex(idsMap: TIdsMap, node?: Node) { // 1. 如果传入的节点不存在,直接返回-1 - if (!node) return -1; + if (!node) { + return -1; + } // 2. 在idsMap数组中查找与当前节点匹配的项 - const foundItem = idsMap.find((item) => item.node === node); + const foundItem = idsMap.find(item => item.node === node); // 3. 如果未找到匹配项,返回-1 - if (foundItem == null) return -1; + if (!foundItem) { + return -1; + } // 4. 如果找到项的index值为null/undefined,返回-1 - if (foundItem.index == null) return -1; + if (foundItem.index === null) { + return -1; + } // 5. 返回有效的index值 return foundItem.index; From 5f49a62bbb37e48bd7128c7367c73d0d485630f7 Mon Sep 17 00:00:00 2001 From: prgrmrwy Date: Sun, 6 Jul 2025 18:19:59 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E6=97=A0=E7=94=A8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/layout-wasm/src/graphviz/dot.ts | 1 - packages/layout-wasm/src/graphviz/edge.ts | 1 - packages/layout-wasm/src/graphviz/mapping.ts | 1 + packages/layout-wasm/src/graphviz/types.ts | 32 +------------------- 4 files changed, 2 insertions(+), 33 deletions(-) diff --git a/packages/layout-wasm/src/graphviz/dot.ts b/packages/layout-wasm/src/graphviz/dot.ts index a67eb316..82b08613 100644 --- a/packages/layout-wasm/src/graphviz/dot.ts +++ b/packages/layout-wasm/src/graphviz/dot.ts @@ -47,7 +47,6 @@ export class Dot { private writeEdges(graph: Graph, nodesEdgesMap: TNodesEdgesMap, idsMap: TIdsMap) { return graph.edges .map((edge) => { - // eslint-disable-next-line no-param-reassign edge.attrs.class = `edge_${nodesEdgesMap.length}`; nodesEdgesMap.push(edge); return `${this.findIndex( diff --git a/packages/layout-wasm/src/graphviz/edge.ts b/packages/layout-wasm/src/graphviz/edge.ts index 4f1b2fad..98fb411c 100644 --- a/packages/layout-wasm/src/graphviz/edge.ts +++ b/packages/layout-wasm/src/graphviz/edge.ts @@ -26,7 +26,6 @@ export class Edge { weight: options.getWeight?.(this.edge), }; } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types public setLayout(path: any, labelPosition: any): void { this.layout = { path, diff --git a/packages/layout-wasm/src/graphviz/mapping.ts b/packages/layout-wasm/src/graphviz/mapping.ts index 0ca57792..976ee323 100644 --- a/packages/layout-wasm/src/graphviz/mapping.ts +++ b/packages/layout-wasm/src/graphviz/mapping.ts @@ -2,6 +2,7 @@ import { Edge } from './edge'; import { Node } from './node'; import { type TNodesEdgesMap } from './types'; +// 用于匹配 antv 数据渲染 export class Mapping { outputMap: TNodesEdgesMap; graphSize: { diff --git a/packages/layout-wasm/src/graphviz/types.ts b/packages/layout-wasm/src/graphviz/types.ts index 0a911721..9da46198 100644 --- a/packages/layout-wasm/src/graphviz/types.ts +++ b/packages/layout-wasm/src/graphviz/types.ts @@ -16,7 +16,6 @@ export interface IObj { [key: string]: unknown; } -// 这个奇怪的结构来自 antv graph getAllNodes 和 getAllEdges 方法 export type TProcessData = { nodes: NodeData[]; edges: EdgeData[]; @@ -34,33 +33,4 @@ export type TNodesEdgesMap = (Node | Edge)[]; export type TIdsMap = { node: Node; index: number; -}[]; - -export interface IDotJson { - bb: string; // x1,y1,x2,y2 - name: string; - randir: string; - nodesep: string; - ranksep: string; - xdotversion: string; - objects: { - _gvid: number; - width: string; // number - height: string; // number - label: string; - shape: 'box' | 'circle'; - pos: string; // x, y - fixedsize: string; // boolean; - }[]; - edges: { - _gvid: number; - label: string; - pos: string; // path - head: number; - tail: number; - lp: string; // x, y labelposition? - weight: string; // number - arrowsize: string; // number - tailclip: string; // boolean - }[]; -} +}[]; \ No newline at end of file