mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 13:00:39 +03:00
F: Use dagre for graph node layout
This commit is contained in:
parent
15c292d240
commit
8f631b1b10
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
@ -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",
|
||||||
|
|
19
rsconcept/frontend/package-lock.json
generated
19
rsconcept/frontend/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 UNIT_WIDTH = 100;
|
const BOOLEAN_WEIGHT = 2;
|
||||||
const MIN_NODE_DISTANCE = 80;
|
const CARTESIAN_WEIGHT = 1;
|
||||||
const LAYOUT_ITERATIONS = 8;
|
|
||||||
|
export function applyLayout(nodes: Node<TMGraphNode>[], edges: Edge[]) {
|
||||||
class LayoutManager {
|
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
nodes: Node<TMGraphNode>[];
|
dagreGraph.setGraph({
|
||||||
|
rankdir: 'BT',
|
||||||
graph = new Graph();
|
ranksep: VERT_SEPARATION,
|
||||||
ranks = new Map<number, number>();
|
nodesep: HOR_SEPARATION,
|
||||||
posX = new Map<number, number>();
|
ranker: 'network-simplex',
|
||||||
posY = new Map<number, number>();
|
align: undefined
|
||||||
|
});
|
||||||
maxRank = 0;
|
nodes.forEach(node => {
|
||||||
virtualCount = 0;
|
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||||
layers: number[][] = [];
|
});
|
||||||
|
|
||||||
constructor(nodes: Node<TMGraphNode>[]) {
|
edges.forEach(edge => {
|
||||||
this.nodes = nodes;
|
dagreGraph.setEdge(edge.source, edge.target, { weight: edge.data ? CARTESIAN_WEIGHT : BOOLEAN_WEIGHT });
|
||||||
this.prepareGraph();
|
});
|
||||||
}
|
|
||||||
|
dagre.layout(dagreGraph);
|
||||||
/** Prepares graph for layout calculations.
|
|
||||||
*
|
nodes.forEach(node => {
|
||||||
* Assumes that nodes are already topologically sorted.
|
const nodeWithPosition = dagreGraph.node(node.id);
|
||||||
* 1. Adds nodes to graph.
|
node.position.x = nodeWithPosition.x - NODE_WIDTH / 2;
|
||||||
* 2. Adds elementary edges to graph.
|
node.position.y = nodeWithPosition.y - NODE_HEIGHT / 2;
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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)!
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user