graph representation rewritten using elkjs
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
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 [centers, setCenters] = useState<Record<string, { x: number, y: number }>>({});
|
||||
const [socketCenters, setSocketCenters] = useState<Record<string, { x: number, y: number }>>({});
|
||||
|
||||
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<string, { x: number, y: number }> = {};
|
||||
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<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]);
|
||||
|
||||
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 (
|
||||
<div className="world-wrap">
|
||||
<div id="world" ref={canvasRef}>
|
||||
@@ -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 && (
|
||||
<NodeLine
|
||||
<LinkLine
|
||||
game={game}
|
||||
key={`${node2d.id}.${idx}`}
|
||||
from={withCenter(node2d)}
|
||||
to={withCenter(target)}
|
||||
from={{ node: withSocket(node2d, 'input', link.resource), resource: link.resource }}
|
||||
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);
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLDivElement, Props>(function NodeCard({ node2d, selected, onSelect }, ref) {
|
||||
const NodeCard = forwardRef<HTMLDivElement, Props>(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 (
|
||||
<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 }}
|
||||
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__title">{node.name}</div>
|
||||
<div className="node__kind">{node.behaviour.kind}</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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<string, Resources>;
|
||||
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)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,10 @@ export const defaultGame: () => SerializedGame = () => ({
|
||||
resource: 'wood',
|
||||
rate: 1,
|
||||
},
|
||||
{
|
||||
resource: 'stone',
|
||||
rate: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -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<Resource, number>;
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
},
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export const ResourceDescriptions: Record<ResourceDescription['id'], ResourceDes
|
||||
wood: { id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' },
|
||||
plank: { id: 'plank', label: 'Доска', color: '#c9c209', icon: 'P' },
|
||||
// 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: '💰' },
|
||||
// knowledge: { id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' },
|
||||
};
|
||||
Reference in New Issue
Block a user