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";