From 8f631b1b1068a453e59f22f62315df1de805c5b4 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Wed, 20 Nov 2024 21:01:42 +0300 Subject: [PATCH] F: Use dagre for graph node layout --- .vscode/settings.json | 6 + rsconcept/frontend/package-lock.json | 19 ++ rsconcept/frontend/package.json | 1 + .../src/context/ConceptOptionsContext.tsx | 2 +- .../dialogs/DlgShowTypeGraph/MGraphFlow.tsx | 5 +- .../DlgShowTypeGraph/graph/MGraphLayout.ts | 201 +++--------------- 6 files changed, 64 insertions(+), 170 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9271082f..d409c286 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -77,6 +77,8 @@ "csrftoken", "cstlist", "csttype", + "dagre", + "dagrejs", "datv", "Debool", "Decart", @@ -91,6 +93,7 @@ "Geologica", "Grammeme", "Grammemes", + "graphlib", "GRND", "IDEF", "impr", @@ -108,6 +111,7 @@ "multiword", "mypy", "nocheck", + "nodesep", "nomn", "nooverlap", "NPRO", @@ -127,6 +131,8 @@ "pylint", "pymorphy", "Quantor", + "rankdir", + "ranksep", "razdel", "reactflow", "reagraph", diff --git a/rsconcept/frontend/package-lock.json b/rsconcept/frontend/package-lock.json index ca050260..aa50055f 100644 --- a/rsconcept/frontend/package-lock.json +++ b/rsconcept/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "1.0.0", "dependencies": { + "@dagrejs/dagre": "^1.1.4", "@lezer/lr": "^1.4.2", "@tanstack/react-table": "^8.20.5", "@uiw/codemirror-themes": "^4.23.6", @@ -729,6 +730,24 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", + "integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", diff --git a/rsconcept/frontend/package.json b/rsconcept/frontend/package.json index e91d6717..0d9e82fe 100644 --- a/rsconcept/frontend/package.json +++ b/rsconcept/frontend/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "@dagrejs/dagre": "^1.1.4", "@lezer/lr": "^1.4.2", "@tanstack/react-table": "^8.20.5", "@uiw/codemirror-themes": "^4.23.6", diff --git a/rsconcept/frontend/src/context/ConceptOptionsContext.tsx b/rsconcept/frontend/src/context/ConceptOptionsContext.tsx index e6b790ed..6c5ae255 100644 --- a/rsconcept/frontend/src/context/ConceptOptionsContext.tsx +++ b/rsconcept/frontend/src/context/ConceptOptionsContext.tsx @@ -159,7 +159,7 @@ export const OptionsState = ({ children }: React.PropsWithChildren) => { id={`${globals.tooltip}`} layer='z-topmost' place='right-start' - className='mt-8 max-w-[20rem]' + className='mt-8 max-w-[20rem] break-words' /> []) { - new LayoutManager(nodes).execute(); -} - -const UNIT_HEIGHT = 100; -const UNIT_WIDTH = 100; -const MIN_NODE_DISTANCE = 80; -const LAYOUT_ITERATIONS = 8; - -class LayoutManager { - nodes: Node[]; - - graph = new Graph(); - ranks = new Map(); - posX = new Map(); - posY = new Map(); - - maxRank = 0; - virtualCount = 0; - layers: number[][] = []; - - constructor(nodes: Node[]) { - this.nodes = nodes; - this.prepareGraph(); - } - - /** Prepares graph for layout calculations. - * - * Assumes that nodes are already topologically sorted. - * 1. Adds nodes to graph. - * 2. Adds elementary edges to graph. - * 3. Splits non-elementary edges via virtual nodes. - */ - private prepareGraph(): void { - this.nodes.forEach(node => { - if (this.maxRank < node.data.rank) { - this.maxRank = node.data.rank; - } - const nodeID = node.data.id; - this.ranks.set(nodeID, node.data.rank); - this.graph.addNode(nodeID); - if (node.data.parents.length === 0) { - return; - } - - const visited = new Set(); - node.data.parents.forEach(parent => { - if (!visited.has(parent)) { - visited.add(parent); - let target = nodeID; - let currentRank = node.data.rank; - const parentRank = this.ranks.get(parent)!; - while (currentRank - 1 > parentRank) { - currentRank = currentRank - 1; - - this.virtualCount = this.virtualCount + 1; - this.ranks.set(-this.virtualCount, currentRank); - this.graph.addEdge(-this.virtualCount, target); - target = -this.virtualCount; - } - this.graph.addEdge(parent, target); - } - }); - }); - } - - execute(): void { - this.calculateLayers(); - this.calculatePositions(); - this.savePositions(); - } - - private calculateLayers(): void { - this.initLayers(); - // TODO: implement ordering algorithm iterations - } - - private initLayers(): void { - this.layers = Array.from({ length: this.maxRank + 1 }, () => []); - - const visited = new Set(); - const dfs = (nodeID: number) => { - if (visited.has(nodeID)) { - return; - } - visited.add(nodeID); - this.layers[this.ranks.get(nodeID)!].push(nodeID); - this.graph.at(nodeID)!.outputs.forEach(dfs); - }; - - const simpleNodes = this.nodes - .filter(node => node.data.rank === 0) - .sort((a, b) => a.data.text.localeCompare(b.data.text)) - .map(node => node.data.id); - - simpleNodes.forEach(dfs); - } - - private calculatePositions(): void { - this.initPositions(); - - for (let i = 0; i < LAYOUT_ITERATIONS; i++) { - this.fixLayersPositions(); - } - } - - private fixLayersPositions(): void { - for (let rank = 1; rank <= this.maxRank; rank++) { - this.layers[rank].reverse().forEach(nodeID => { - const inputs = this.graph.at(nodeID)!.inputs; - const currentPos = this.posX.get(nodeID)!; - if (inputs.length === 1) { - const parent = inputs[0]; - const parentPos = this.posX.get(parent)!; - if (currentPos === parentPos) { - return; - } - if (currentPos > parentPos) { - this.tryMoveNodeX(parent, currentPos); - } else { - this.tryMoveNodeX(nodeID, parentPos); - } - } else if (inputs.length % 2 === 1) { - const median = inputs[Math.floor(inputs.length / 2)]; - const medianPos = this.posX.get(median)!; - if (currentPos === medianPos) { - return; - } - this.tryMoveNodeX(nodeID, medianPos); - } else { - const median1 = inputs[Math.floor(inputs.length / 2)]; - const median2 = inputs[Math.floor(inputs.length / 2) - 1]; - const medianPos = (this.posX.get(median1)! + this.posX.get(median2)!) / 2; - this.tryMoveNodeX(nodeID, medianPos); - } - }); - } - } - - private tryMoveNodeX(nodeID: number, targetX: number) { - const rank = this.ranks.get(nodeID)!; - if (this.layers[rank].some(id => id !== nodeID && Math.abs(targetX - this.posX.get(id)!) < MIN_NODE_DISTANCE)) { - return; - } - this.posX.set(nodeID, targetX); - } - - private initPositions(): void { - this.layers.forEach((layer, rank) => { - layer.forEach((nodeID, index) => { - this.posX.set(nodeID, index * UNIT_WIDTH); - this.posY.set(nodeID, -rank * UNIT_HEIGHT); - }); - }); - } - - private savePositions(): void { - this.nodes.forEach(node => { - const nodeID = node.data.id; - node.position = { - x: this.posX.get(nodeID)!, - y: this.posY.get(nodeID)! - }; - }); - } +const NODE_WIDTH = 44; +const NODE_HEIGHT = 44; +const HOR_SEPARATION = 40; +const VERT_SEPARATION = 40; + +const BOOLEAN_WEIGHT = 2; +const CARTESIAN_WEIGHT = 1; + +export function applyLayout(nodes: Node[], edges: Edge[]) { + const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + dagreGraph.setGraph({ + rankdir: 'BT', + ranksep: VERT_SEPARATION, + nodesep: HOR_SEPARATION, + ranker: 'network-simplex', + align: undefined + }); + nodes.forEach(node => { + dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + }); + + edges.forEach(edge => { + dagreGraph.setEdge(edge.source, edge.target, { weight: edge.data ? CARTESIAN_WEIGHT : BOOLEAN_WEIGHT }); + }); + + dagre.layout(dagreGraph); + + nodes.forEach(node => { + const nodeWithPosition = dagreGraph.node(node.id); + node.position.x = nodeWithPosition.x - NODE_WIDTH / 2; + node.position.y = nodeWithPosition.y - NODE_HEIGHT / 2; + }); }