F: Use dagre for graph node layout

This commit is contained in:
Ivan 2024-11-20 21:01:42 +03:00
parent 15c292d240
commit 8f631b1b10
6 changed files with 64 additions and 170 deletions

View File

@ -77,6 +77,8 @@
"csrftoken", "csrftoken",
"cstlist", "cstlist",
"csttype", "csttype",
"dagre",
"dagrejs",
"datv", "datv",
"Debool", "Debool",
"Decart", "Decart",
@ -91,6 +93,7 @@
"Geologica", "Geologica",
"Grammeme", "Grammeme",
"Grammemes", "Grammemes",
"graphlib",
"GRND", "GRND",
"IDEF", "IDEF",
"impr", "impr",
@ -108,6 +111,7 @@
"multiword", "multiword",
"mypy", "mypy",
"nocheck", "nocheck",
"nodesep",
"nomn", "nomn",
"nooverlap", "nooverlap",
"NPRO", "NPRO",
@ -127,6 +131,8 @@
"pylint", "pylint",
"pymorphy", "pymorphy",
"Quantor", "Quantor",
"rankdir",
"ranksep",
"razdel", "razdel",
"reactflow", "reactflow",
"reagraph", "reagraph",

View File

@ -8,6 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"@uiw/codemirror-themes": "^4.23.6", "@uiw/codemirror-themes": "^4.23.6",
@ -729,6 +730,24 @@
"w3c-keyname": "^2.2.4" "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": { "node_modules/@emotion/babel-plugin": {
"version": "11.12.0", "version": "11.12.0",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz",

View File

@ -12,6 +12,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"@uiw/codemirror-themes": "^4.23.6", "@uiw/codemirror-themes": "^4.23.6",

View File

@ -159,7 +159,7 @@ export const OptionsState = ({ children }: React.PropsWithChildren) => {
id={`${globals.tooltip}`} id={`${globals.tooltip}`}
layer='z-topmost' layer='z-topmost'
place='right-start' place='right-start'
className='mt-8 max-w-[20rem]' className='mt-8 max-w-[20rem] break-words'
/> />
<Tooltip <Tooltip
float float

View File

@ -15,7 +15,7 @@ interface MGraphFlowProps {
} }
function MGraphFlow({ data }: MGraphFlowProps) { function MGraphFlow({ data }: MGraphFlowProps) {
const [nodes, setNodes] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]); const [edges, setEdges] = useEdgesState([]);
const flow = useReactFlow(); const flow = useReactFlow();
@ -49,7 +49,7 @@ function MGraphFlow({ data }: MGraphFlowProps) {
}); });
}); });
applyLayout(newNodes); applyLayout(newNodes, newEdges);
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
@ -62,6 +62,7 @@ function MGraphFlow({ data }: MGraphFlowProps) {
edges={edges} edges={edges}
edgesFocusable={false} edgesFocusable={false}
nodesFocusable={false} nodesFocusable={false}
onNodesChange={onNodesChange}
nodeTypes={TMGraphNodeTypes} nodeTypes={TMGraphNodeTypes}
edgeTypes={TMGraphEdgeTypes} edgeTypes={TMGraphEdgeTypes}
fitView fitView

View File

@ -1,171 +1,38 @@
import { Node } from 'reactflow'; import dagre from '@dagrejs/dagre';
import { Edge, Node } from 'reactflow';
import { Graph } from '@/models/Graph';
import { TMGraphNode } from '@/models/TMGraph'; import { TMGraphNode } from '@/models/TMGraph';
export function applyLayout(nodes: Node<TMGraphNode>[]) { const NODE_WIDTH = 44;
new LayoutManager(nodes).execute(); const NODE_HEIGHT = 44;
} const HOR_SEPARATION = 40;
const VERT_SEPARATION = 40;
const UNIT_HEIGHT = 100; const BOOLEAN_WEIGHT = 2;
const UNIT_WIDTH = 100; const CARTESIAN_WEIGHT = 1;
const MIN_NODE_DISTANCE = 80;
const LAYOUT_ITERATIONS = 8;
class LayoutManager { export function applyLayout(nodes: Node<TMGraphNode>[], edges: Edge[]) {
nodes: Node<TMGraphNode>[]; const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({
graph = new Graph(); rankdir: 'BT',
ranks = new Map<number, number>(); ranksep: VERT_SEPARATION,
posX = new Map<number, number>(); nodesep: HOR_SEPARATION,
posY = new Map<number, number>(); ranker: 'network-simplex',
align: undefined
maxRank = 0;
virtualCount = 0;
layers: number[][] = [];
constructor(nodes: Node<TMGraphNode>[]) {
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<number>();
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);
}
}); });
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;
}); });
} }
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<number>();
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)!
};
});
}
}