graph representation rewritten using elkjs

This commit is contained in:
azykov@mail.ru
2026-08-31 21:29:52 +03:00
parent 9eb67275d7
commit af4d68e1c8
11 changed files with 158 additions and 53 deletions
+36 -10
View File
@@ -3,7 +3,7 @@ import NodeCard from './NodeCard';
import { Game } from '../types'; import { Game } from '../types';
import { build2dNodes, Node2D } from '../ui/graph'; import { build2dNodes, Node2D } from '../ui/graph';
import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { NodeLine } from './NodeLine'; import { LinkLine } from './LinkLine';
interface Props { interface Props {
game: Game; game: Game;
@@ -19,8 +19,10 @@ export const GameCanvas = function ({
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const nodeElsRef = useRef<Map<string, HTMLDivElement>>(new Map()); const nodeElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
const socketElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
const [size, setSize] = useState({ width: 0, height: 0 }); const [size, setSize] = useState({ width: 0, height: 0 });
const [centers, setCenters] = useState<Record<string, { x: number, y: number }>>({}); const [centers, setCenters] = useState<Record<string, { x: number, y: number }>>({});
const [socketCenters, setSocketCenters] = useState<Record<string, { x: number, y: number }>>({});
useEffect(() => { useEffect(() => {
const el = canvasRef.current; const el = canvasRef.current;
@@ -48,17 +50,29 @@ export const GameCanvas = function ({
const worldEl = canvasRef.current; const worldEl = canvasRef.current;
if (!worldEl) return; if (!worldEl) return;
const worldRect = worldEl.getBoundingClientRect(); const worldRect = worldEl.getBoundingClientRect();
const next: Record<string, { x: number, y: number }> = {};
for (const node2d of nodes2d) { function centerOf(el: HTMLDivElement) {
const el = nodeElsRef.current.get(node2d.id);
if (!el) continue;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
next[node2d.id] = { return {
x: rect.left - worldRect.left + rect.width / 2, x: rect.left - worldRect.left + rect.width / 2,
y: rect.top - worldRect.top + rect.height / 2, y: rect.top - worldRect.top + rect.height / 2,
}; };
} }
setCenters(next);
const nextCenters: Record<string, { x: number, y: number }> = {};
const nextSocketCenters: Record<string, { x: number, y: number }> = {};
for (const node2d of nodes2d) {
const el = nodeElsRef.current.get(node2d.id);
if (el) nextCenters[node2d.id] = centerOf(el);
for (const socket of node2d.node.behaviour.getDisplayInfo().sockets) {
const socketKey = `${node2d.id}.${socket.type}.${socket.index}`;
const socketEl = socketElsRef.current.get(socketKey);
if (socketEl) nextSocketCenters[socketKey] = centerOf(socketEl);
}
}
setCenters(nextCenters);
setSocketCenters(nextSocketCenters);
}, [nodes2d]); }, [nodes2d]);
function withCenter(node2d: Node2D): Node2D { function withCenter(node2d: Node2D): Node2D {
@@ -66,6 +80,13 @@ export const GameCanvas = function ({
return center ? { ...node2d, position: center } : node2d; return center ? { ...node2d, position: center } : node2d;
} }
function withSocket(node2d: Node2D, type: 'input' | 'output', resource: string): Node2D {
const socket = node2d.node.behaviour.getDisplayInfo().sockets
.find((s) => s.type === type && s.resource === resource);
const center = socket && socketCenters[`${node2d.id}.${type}.${socket.index}`];
return center ? { ...node2d, position: center } : withCenter(node2d);
}
return ( return (
<div className="world-wrap"> <div className="world-wrap">
<div id="world" ref={canvasRef}> <div id="world" ref={canvasRef}>
@@ -74,11 +95,11 @@ export const GameCanvas = function ({
node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => { node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => {
const target = nodes2d.find((n2d) => n2d.id === link.target); const target = nodes2d.find((n2d) => n2d.id === link.target);
return target && ( return target && (
<NodeLine <LinkLine
game={game} game={game}
key={`${node2d.id}.${idx}`} key={`${node2d.id}.${idx}`}
from={withCenter(node2d)} from={{ node: withSocket(node2d, 'input', link.resource), resource: link.resource }}
to={withCenter(target)} to={{ node: withSocket(target, 'output', link.resource), resource: link.resource }}
/> />
); );
})) }))
@@ -90,6 +111,11 @@ export const GameCanvas = function ({
if (el) nodeElsRef.current.set(node2d.id, el); if (el) nodeElsRef.current.set(node2d.id, el);
else nodeElsRef.current.delete(node2d.id); else nodeElsRef.current.delete(node2d.id);
}} }}
onSocketRef={(type, index, el) => {
const socketKey = `${node2d.id}.${type}.${index}`;
if (el) socketElsRef.current.set(socketKey, el);
else socketElsRef.current.delete(socketKey);
}}
game={game} game={game}
key={node2d.id} key={node2d.id}
node2d={node2d} node2d={node2d}
+45
View File
@@ -0,0 +1,45 @@
import { Game, Resource } from "../types";
import { Node2D } from "../ui/graph";
type Props = {
game: Game,
from: { node: Node2D, resource: Resource },
to: { node: Node2D, resource: Resource },
}
export const LinkLine = function ({ from, to }: Props) {
const markerId = `node-line-arrow-${from.node.id}-${to.node.id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
const fromSocket = from.node.node.behaviour.getDisplayInfo().sockets.find((s) => s.type === 'input' && s.resource == from.resource);
const toSocket = to.node.node.behaviour.getDisplayInfo().sockets.find((s) => s.type === 'output' && s.resource == to.resource);
if (!fromSocket || !toSocket)
return null;
return (
<svg style={{zIndex: 1000}}>
<defs>
<marker
id={markerId}
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 z" fill="white" />
</marker>
</defs>
<line
x1={from.node.position.x}
y1={from.node.position.y}
x2={to.node.position.x}
y2={to.node.position.y}
stroke="white"
strokeWidth={2}
markerStart={`url(#${markerId})`}
/>
</svg>
);
};
+30 -1
View File
@@ -8,15 +8,18 @@ interface Props {
node2d: Node2D; node2d: Node2D;
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
onSocketRef?: (type: 'input' | 'output', index: number, el: HTMLDivElement | null) => void;
} }
const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, selected, onSelect }, ref) { const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, selected, onSelect, onSocketRef }, ref) {
const node = node2d.node; const node = node2d.node;
function handleClick() { function handleClick() {
onSelect(); onSelect();
} }
const info = node.behaviour.getDisplayInfo(); const info = node.behaviour.getDisplayInfo();
const inputSockets = info.sockets.filter((s) => s.type === 'input');
const outputSockets = info.sockets.filter((s) => s.type === 'output');
return ( return (
<div <div
@@ -25,6 +28,32 @@ const NodeCard = forwardRef<HTMLDivElement, Props>(function NodeCard({ node2d, s
style={{ left: node2d.position.x, top: node2d.position.y, width: 100 }} style={{ left: node2d.position.x, top: node2d.position.y, width: 100 }}
onClick={handleClick} onClick={handleClick}
> >
<div className="node__sockets node__sockets--input">
{
inputSockets.map((socket) => (
<div
key={`input.${socket.index}`}
ref={(el) => onSocketRef?.('input', socket.index, el)}
className="node__socket"
style={{ background: ResourceDescriptions[socket.resource].color }}
title={`${ResourceDescriptions[socket.resource].label} (вход)`}
/>
))
}
</div>
<div className="node__sockets node__sockets--output">
{
outputSockets.map((socket) => (
<div
key={`output.${socket.index}`}
ref={(el) => onSocketRef?.('output', socket.index, el)}
className="node__socket"
style={{ background: ResourceDescriptions[socket.resource].color }}
title={`${ResourceDescriptions[socket.resource].label} (выход)`}
/>
))
}
</div>
<div className="node__id">{node.id}</div> <div className="node__id">{node.id}</div>
<div className="node__title">{node.name}</div> <div className="node__title">{node.name}</div>
<div className="node__kind">{node.behaviour.kind}</div> <div className="node__kind">{node.behaviour.kind}</div>
-39
View File
@@ -1,39 +0,0 @@
import { Game } from "../types";
import { Node2D } from "../ui/graph";
type Props = {
game: Game,
from: Node2D,
to: Node2D,
}
export const NodeLine = function ({ from, to }: Props) {
const markerId = `node-line-arrow-${from.id}-${to.id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
return (
<svg style={{zIndex: 1000}}>
<defs>
<marker
id={markerId}
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 z" fill="white" />
</marker>
</defs>
<line
x1={from.position.x}
y1={from.position.y}
x2={to.position.x}
y2={to.position.y}
stroke="white"
strokeWidth={2}
markerStart={`url(#${markerId})`}
/>
</svg>
);
};
+19
View File
@@ -4,6 +4,12 @@ export type BehaviourResourcePin = {
resource: Resource; resource: Resource;
} }
export type BehaviourSocket = {
type: 'input' | 'output';
index: number; // within its type
resource: Resource;
}
export type BehaviourLink = { export type BehaviourLink = {
target: Behaviour['id'], target: Behaviour['id'],
resource: Resource; resource: Resource;
@@ -26,6 +32,7 @@ export type BehaviourOutputs = {
export type BehaviourDisplayInfo = { export type BehaviourDisplayInfo = {
storages: Record<string, Resources>; storages: Record<string, Resources>;
sockets: BehaviourSocket[];
links: BehaviourLink[]; links: BehaviourLink[];
} }
@@ -80,6 +87,18 @@ export class Converter extends Behaviour {
return { return {
storages: {}, storages: {},
links: this.inputs.pins.map((input) => input.link), links: this.inputs.pins.map((input) => input.link),
sockets: [
...this.inputs.pins.map((input, index) => ({
type: 'input',
index,
resource: input.resource,
} satisfies BehaviourSocket)),
...this.outputs.pins.map((output, index) => ({
type: 'output',
index,
resource: output.resource,
} satisfies BehaviourSocket)),
],
} }
} }
+21
View File
@@ -230,6 +230,27 @@ select {
white-space: nowrap; white-space: nowrap;
} }
.node__sockets {
position: absolute;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.node__sockets--input { left: -5px; }
.node__sockets--output { right: -5px; }
.node__socket {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--panel);
box-shadow: 0 0 0 1px var(--edge-color);
}
/* ------------------------------------------------------------ sidebar --- */ /* ------------------------------------------------------------ sidebar --- */
.sidebar { .sidebar {
+4
View File
@@ -18,6 +18,10 @@ export const defaultGame: () => SerializedGame = () => ({
resource: 'wood', resource: 'wood',
rate: 1, rate: 1,
}, },
{
resource: 'stone',
rate: 0.1,
},
], ],
}, },
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
import { Behaviour } from "../engine/functions"; import { Behaviour } from "../engine/functions";
import { deserializeBehaviour } from "../engine/saveLoad"; import { deserializeBehaviour } from "../engine/saveLoad";
export type Resource = 'wood' | 'plank'; export type Resource = 'wood' | 'plank' | 'stone';
export type Resources = Record<Resource, number>; export type Resources = Record<Resource, number>;
+1 -1
View File
@@ -19,7 +19,7 @@ export async function build2dNodes(nodes: Node[], area: { width: number, height:
id: "root", id: "root",
layoutOptions: { layoutOptions: {
"elk.algorithm": "layered", "elk.algorithm": "layered",
"elk.direction": "RIGHT", "elk.direction": "LEFT",
"elk.spacing.nodeNode": "60", "elk.spacing.nodeNode": "60",
"elk.layered.spacing.nodeNodeBetweenLayers": "100", "elk.layered.spacing.nodeNodeBetweenLayers": "100",
}, },
View File
+1 -1
View File
@@ -11,7 +11,7 @@ export const ResourceDescriptions: Record<ResourceDescription['id'], ResourceDes
wood: { id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' }, wood: { id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' },
plank: { id: 'plank', label: 'Доска', color: '#c9c209', icon: 'P' }, plank: { id: 'plank', label: 'Доска', color: '#c9c209', icon: 'P' },
// iron: { id: 'iron', label: 'Железо', color: '#9aa7b0', icon: '⛓' }, // iron: { id: 'iron', label: 'Железо', color: '#9aa7b0', icon: '⛓' },
// stone: { id: 'stone', label: 'Камень', color: '#8a8a8a', icon: '🪨' }, stone: { id: 'stone', label: 'Камень', color: '#8a8a8a', icon: '🪨' },
// gold: { id: 'gold', label: 'Золото', color: '#d9b64e', icon: '💰' }, // gold: { id: 'gold', label: 'Золото', color: '#d9b64e', icon: '💰' },
// knowledge: { id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' }, // knowledge: { id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' },
}; };