rewritten using elkjs

This commit is contained in:
azykov@mail.ru
2026-08-31 21:03:22 +03:00
parent f76370151e
commit 9eb67275d7
5 changed files with 57 additions and 114 deletions
+7 -40
View File
@@ -8,8 +8,7 @@
"name": "resource-graph-sim",
"version": "0.1.0",
"dependencies": {
"ngraph.forcelayout": "^3.3.1",
"ngraph.graph": "^20.1.2",
"elkjs": "^0.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -1326,6 +1325,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/elkjs": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz",
"integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==",
"license": "EPL-2.0 OR GPL-3.0-or-later"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1480,44 +1485,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/ngraph.events": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
"license": "BSD-3-Clause"
},
"node_modules/ngraph.forcelayout": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz",
"integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==",
"license": "BSD-3-Clause",
"dependencies": {
"ngraph.events": "^1.0.0",
"ngraph.merge": "^1.0.0",
"ngraph.random": "^1.0.0"
}
},
"node_modules/ngraph.graph": {
"version": "20.1.2",
"resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz",
"integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"ngraph.events": "^1.4.0"
}
},
"node_modules/ngraph.merge": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz",
"integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==",
"license": "MIT"
},
"node_modules/ngraph.random": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz",
"integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==",
"license": "BSD-3-Clause"
},
"node_modules/node-releases": {
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
+1 -2
View File
@@ -9,8 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"ngraph.forcelayout": "^3.3.1",
"ngraph.graph": "^20.1.2",
"elkjs": "^0.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
+11 -5
View File
@@ -2,7 +2,7 @@ import type { Selection } from '../App';
import NodeCard from './NodeCard';
import { Game } from '../types';
import { build2dNodes, Node2D } from '../ui/graph';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { NodeLine } from './NodeLine';
interface Props {
@@ -33,10 +33,16 @@ export const GameCanvas = function ({
return () => observer.disconnect();
}, []);
const nodes2d = useMemo(
() => build2dNodes(game.nodes, { width: size.width, height: size.height, padding: 40 }),
[game.nodes, size.width, size.height],
);
const [nodes2d, setNodes2d] = useState<Node2D[]>([]);
useEffect(() => {
let cancelled = false;
build2dNodes(game.nodes, { width: size.width, height: size.height, padding: 40 })
.then((result) => {
if (!cancelled) setNodes2d(result);
});
return () => { cancelled = true; };
}, [game.nodes, size.width, size.height]);
useLayoutEffect(() => {
const worldEl = canvasRef.current;
+1 -1
View File
@@ -41,7 +41,7 @@ const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, s
}
<div className="node__links">
{
info.links.map((link) => <div>{link.target}/{link.resource}</div>)
info.links.map((link) => <div key={`${link.target}/${link.resource}`}>{link.target}/{link.resource}</div>)
}
</div>
</div>
+37 -66
View File
@@ -1,8 +1,10 @@
import createGraph, { Graph } from "ngraph.graph";
import createLayout from "ngraph.forcelayout";
import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js";
import { Node } from "../types";
const ITERATION_COUNT = 10;
const elk = new ELK();
const NODE_WIDTH = 100;
const NODE_HEIGHT = 90;
export type Node2D = {
id: Node['id'];
@@ -10,70 +12,39 @@ export type Node2D = {
node: Node;
}
export function build2dNodes(nodes: Node[], area: { width: number, height: number, padding: number }): Node2D[] {
const graph = createGraph();
for (const node of nodes) {
graph.addNode(node.behaviour.id, { node });
}
for (const node of nodes) {
for (const link of node.behaviour.getDisplayInfo().links)
graph.addLink(node.behaviour.id, link.target);
}
return buildGraph2dNodes(graph, area);
}
export async function build2dNodes(nodes: Node[], area: { width: number, height: number, padding: number }): Promise<Node2D[]> {
const nodesById = new Map(nodes.map((node) => [node.behaviour.id, node]));
function buildGraph2dNodes(graph: Graph, area: { width: number, height: number, padding: number }): Node2D[] {
const layout = createLayout(
graph,
{
springLength: 300,
// springTransform: (link, spring) => {
// const from = graph.getNode(link.fromId).data.radius;
// const to = graph.getNode(link.toId).data.radius;
// spring.length = from + to + 10; // отступ 10px
// },
const graph: ElkNode = {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.spacing.nodeNode": "60",
"elk.layered.spacing.nodeNodeBetweenLayers": "100",
},
);
children: nodes.map((node) => ({
id: node.behaviour.id,
width: NODE_WIDTH,
height: NODE_HEIGHT,
})),
edges: nodes.flatMap((node) =>
node.behaviour.getDisplayInfo().links.map((link, idx) => ({
id: `${node.behaviour.id}->${link.target}.${idx}`,
sources: [node.behaviour.id],
targets: [link.target],
}))
),
};
function clampToBounds() {
const minX = area.padding;
const maxX = area.width - area.padding;
const minY = area.padding;
const maxY = area.height - area.padding;
layout.forEachBody((body) => {
if (body.pos.x < minX) { body.pos.x = minX; body.velocity.x = 0; }
if (body.pos.x > maxX) { body.pos.x = maxX; body.velocity.x = 0; }
if (body.pos.y < minY) { body.pos.y = minY; body.velocity.y = 0; }
if (body.pos.y > maxY) { body.pos.y = maxY; body.velocity.y = 0; }
});
}
for (var i = 0; i < ITERATION_COUNT; ++i) {
layout.step();
// clampToBounds();
}
// const rect = layout.getGraphRect();
// const W = area.width, H = 600, PADDING = 40;
// const scaleX = (area.width - area.padding * 2) / (rect.x2 - rect.x1);
// const scaleY = (area.height - area.padding * 2) / (rect.y2 - rect.y1);
// const scale = Math.min(scaleX, scaleY); // сохраняем пропорции
// function toCanvas(pos: { x: number; y: number }) {
// return {
// x: area.padding + (pos.x - rect.x1) * scale,
// y: area.padding + (pos.y - rect.y1) * scale,
// };
// }
const nodes: Node2D[] = [];
layout.forEachBody((body, nodeId) => {
nodes.push({
id: nodeId.toString(),
position: { x: body.pos.x + area.width / 2, y: body.pos.y + area.height / 2 },
node: graph.getNode(nodeId)?.data.node,
});
});
return nodes;
const layout = await elk.layout(graph);
return (layout.children ?? []).map((child) => ({
id: child.id,
position: {
x: (child.x ?? 0) + area.padding,
y: (child.y ?? 0) + area.padding,
},
node: nodesById.get(child.id)!,
}));
}