From af4d68e1c89203400ec726f5d7ff6d4d5cf9c156 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Mon, 31 Aug 2026 21:29:52 +0300 Subject: [PATCH] graph representation rewritten using elkjs --- src/components/GameCanvas.tsx | 46 +++++++++++++++++++++++++++-------- src/components/LinkLine.tsx | 45 ++++++++++++++++++++++++++++++++++ src/components/NodeCard.tsx | 31 ++++++++++++++++++++++- src/components/NodeLine.tsx | 39 ----------------------------- src/engine/functions/index.ts | 19 +++++++++++++++ src/index.css | 21 ++++++++++++++++ src/prefabs/game.ts | 4 +++ src/types/index.ts | 2 +- src/ui/graph.ts | 2 +- src/ui/node.ts | 0 src/ui/resource.ts | 2 +- 11 files changed, 158 insertions(+), 53 deletions(-) create mode 100644 src/components/LinkLine.tsx delete mode 100644 src/components/NodeLine.tsx delete mode 100644 src/ui/node.ts diff --git a/src/components/GameCanvas.tsx b/src/components/GameCanvas.tsx index 309f7e1..92fc9d4 100644 --- a/src/components/GameCanvas.tsx +++ b/src/components/GameCanvas.tsx @@ -3,7 +3,7 @@ import NodeCard from './NodeCard'; import { Game } from '../types'; import { build2dNodes, Node2D } from '../ui/graph'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { NodeLine } from './NodeLine'; +import { LinkLine } from './LinkLine'; interface Props { game: Game; @@ -19,8 +19,10 @@ export const GameCanvas = function ({ const canvasRef = useRef(null); const nodeElsRef = useRef>(new Map()); + const socketElsRef = useRef>(new Map()); const [size, setSize] = useState({ width: 0, height: 0 }); const [centers, setCenters] = useState>({}); + const [socketCenters, setSocketCenters] = useState>({}); useEffect(() => { const el = canvasRef.current; @@ -48,17 +50,29 @@ export const GameCanvas = function ({ const worldEl = canvasRef.current; if (!worldEl) return; const worldRect = worldEl.getBoundingClientRect(); - const next: Record = {}; - for (const node2d of nodes2d) { - const el = nodeElsRef.current.get(node2d.id); - if (!el) continue; + + function centerOf(el: HTMLDivElement) { const rect = el.getBoundingClientRect(); - next[node2d.id] = { + return { x: rect.left - worldRect.left + rect.width / 2, y: rect.top - worldRect.top + rect.height / 2, }; } - setCenters(next); + + const nextCenters: Record = {}; + const nextSocketCenters: Record = {}; + 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]); function withCenter(node2d: Node2D): Node2D { @@ -66,6 +80,13 @@ export const GameCanvas = function ({ 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 (
@@ -74,11 +95,11 @@ export const GameCanvas = function ({ node2d.node.behaviour.getDisplayInfo().links.map((link, idx) => { const target = nodes2d.find((n2d) => n2d.id === link.target); return target && ( - ); })) @@ -90,6 +111,11 @@ export const GameCanvas = function ({ if (el) nodeElsRef.current.set(node2d.id, el); 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} key={node2d.id} node2d={node2d} diff --git a/src/components/LinkLine.tsx b/src/components/LinkLine.tsx new file mode 100644 index 0000000..95a83de --- /dev/null +++ b/src/components/LinkLine.tsx @@ -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 ( + + + + + + + + + ); +}; diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 4c829aa..3bf1f9d 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -8,15 +8,18 @@ interface Props { node2d: Node2D; selected: boolean; onSelect: () => void; + onSocketRef?: (type: 'input' | 'output', index: number, el: HTMLDivElement | null) => void; } -const NodeCard = forwardRef(function NodeCard({ node2d, selected, onSelect }, ref) { +const NodeCard = forwardRef(function NodeCard({ node2d, selected, onSelect, onSocketRef }, ref) { const node = node2d.node; function handleClick() { onSelect(); } const info = node.behaviour.getDisplayInfo(); + const inputSockets = info.sockets.filter((s) => s.type === 'input'); + const outputSockets = info.sockets.filter((s) => s.type === 'output'); return (
(function NodeCard({ node2d, s style={{ left: node2d.position.x, top: node2d.position.y, width: 100 }} onClick={handleClick} > +
+ { + inputSockets.map((socket) => ( +
onSocketRef?.('input', socket.index, el)} + className="node__socket" + style={{ background: ResourceDescriptions[socket.resource].color }} + title={`${ResourceDescriptions[socket.resource].label} (вход)`} + /> + )) + } +
+
+ { + outputSockets.map((socket) => ( +
onSocketRef?.('output', socket.index, el)} + className="node__socket" + style={{ background: ResourceDescriptions[socket.resource].color }} + title={`${ResourceDescriptions[socket.resource].label} (выход)`} + /> + )) + } +
{node.id}
{node.name}
{node.behaviour.kind}
diff --git a/src/components/NodeLine.tsx b/src/components/NodeLine.tsx deleted file mode 100644 index 01e56b6..0000000 --- a/src/components/NodeLine.tsx +++ /dev/null @@ -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 ( - - - - - - - - - ); -}; diff --git a/src/engine/functions/index.ts b/src/engine/functions/index.ts index ca4c4a0..23e2b2f 100644 --- a/src/engine/functions/index.ts +++ b/src/engine/functions/index.ts @@ -4,6 +4,12 @@ export type BehaviourResourcePin = { resource: Resource; } +export type BehaviourSocket = { + type: 'input' | 'output'; + index: number; // within its type + resource: Resource; +} + export type BehaviourLink = { target: Behaviour['id'], resource: Resource; @@ -26,6 +32,7 @@ export type BehaviourOutputs = { export type BehaviourDisplayInfo = { storages: Record; + sockets: BehaviourSocket[]; links: BehaviourLink[]; } @@ -80,6 +87,18 @@ export class Converter extends Behaviour { return { storages: {}, 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)), + ], } } diff --git a/src/index.css b/src/index.css index 00b7ba7..48dee49 100644 --- a/src/index.css +++ b/src/index.css @@ -230,6 +230,27 @@ select { 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 { diff --git a/src/prefabs/game.ts b/src/prefabs/game.ts index 77be7c0..12fa3d4 100644 --- a/src/prefabs/game.ts +++ b/src/prefabs/game.ts @@ -18,6 +18,10 @@ export const defaultGame: () => SerializedGame = () => ({ resource: 'wood', rate: 1, }, + { + resource: 'stone', + rate: 0.1, + }, ], }, }, diff --git a/src/types/index.ts b/src/types/index.ts index 44da47c..15f49f2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,7 @@ import { Behaviour } from "../engine/functions"; import { deserializeBehaviour } from "../engine/saveLoad"; -export type Resource = 'wood' | 'plank'; +export type Resource = 'wood' | 'plank' | 'stone'; export type Resources = Record; diff --git a/src/ui/graph.ts b/src/ui/graph.ts index ba232fb..8f8b22f 100644 --- a/src/ui/graph.ts +++ b/src/ui/graph.ts @@ -19,7 +19,7 @@ export async function build2dNodes(nodes: Node[], area: { width: number, height: id: "root", layoutOptions: { "elk.algorithm": "layered", - "elk.direction": "RIGHT", + "elk.direction": "LEFT", "elk.spacing.nodeNode": "60", "elk.layered.spacing.nodeNodeBetweenLayers": "100", }, diff --git a/src/ui/node.ts b/src/ui/node.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/ui/resource.ts b/src/ui/resource.ts index ea8feb4..499868e 100644 --- a/src/ui/resource.ts +++ b/src/ui/resource.ts @@ -11,7 +11,7 @@ export const ResourceDescriptions: Record