@stuly/anode-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +22 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +65 -0
- package/dist/elements/Background.d.ts +12 -0
- package/dist/elements/Background.js +27 -0
- package/dist/elements/Controls.d.ts +8 -0
- package/dist/elements/Controls.js +96 -0
- package/dist/elements/Group.d.ts +10 -0
- package/dist/elements/Group.js +86 -0
- package/dist/elements/Link.d.ts +16 -0
- package/dist/elements/Link.js +191 -0
- package/dist/elements/MiniMap.d.ts +10 -0
- package/dist/elements/MiniMap.js +80 -0
- package/dist/elements/Node.d.ts +14 -0
- package/dist/elements/Node.js +122 -0
- package/dist/elements/Panel.d.ts +11 -0
- package/dist/elements/Panel.js +39 -0
- package/dist/elements/Socket.d.ts +14 -0
- package/dist/elements/Socket.js +106 -0
- package/dist/elements/World.d.ts +45 -0
- package/dist/elements/World.js +615 -0
- package/dist/hooks.d.ts +15 -0
- package/dist/hooks.js +156 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 luxluth <delphin.blehoussi93@gmail.com>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# anode-react
|
|
2
|
+
|
|
3
|
+
React bindings and components for Anode, providing a declarative layer over
|
|
4
|
+
the headless core engine.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @stuly/anode-react @stuly/anode
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Components & Hooks
|
|
13
|
+
|
|
14
|
+
- **World:** The primary canvas component for rendering the node graph.
|
|
15
|
+
- **Socket:** A component for rendering connection points within nodes.
|
|
16
|
+
- **Hooks:**
|
|
17
|
+
- `useAnode()`: Access the core engine.
|
|
18
|
+
- `useSocketValue()`: Subscribe to reactive data flow.
|
|
19
|
+
- `useVisibleNodes()`: Optimized spatial culling.
|
|
20
|
+
- `useEntitySockets()`: Reactive socket management.
|
|
21
|
+
|
|
22
|
+
For detailed documentation and usage examples, see the [root README](../../README.md).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Context } from "@stuly/anode";
|
|
3
|
+
|
|
4
|
+
//#region src/context.d.ts
|
|
5
|
+
interface Viewport {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
k: number;
|
|
9
|
+
}
|
|
10
|
+
declare const useAnode: () => Context<any>;
|
|
11
|
+
declare const useViewport: () => {
|
|
12
|
+
viewport: Viewport;
|
|
13
|
+
setViewport: (v: Viewport) => void;
|
|
14
|
+
screenToWorld: (clientX: number, clientY: number) => {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
declare const useSelection: () => {
|
|
20
|
+
selection: {
|
|
21
|
+
nodes: Set<number>;
|
|
22
|
+
links: Set<number>;
|
|
23
|
+
};
|
|
24
|
+
setSelection: React.Dispatch<React.SetStateAction<{
|
|
25
|
+
nodes: Set<number>;
|
|
26
|
+
links: Set<number>;
|
|
27
|
+
}>>;
|
|
28
|
+
};
|
|
29
|
+
declare const AnodeProvider: React.FC<{
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
context?: Context;
|
|
32
|
+
}>;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { AnodeProvider, useAnode, useSelection, useViewport };
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Context } from "@stuly/anode";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/context.tsx
|
|
6
|
+
const AnodeReactContext = createContext(null);
|
|
7
|
+
const useAnode = () => {
|
|
8
|
+
const value = useContext(AnodeReactContext);
|
|
9
|
+
if (!value) throw new Error("useAnode must be used within an AnodeProvider");
|
|
10
|
+
return value.ctx;
|
|
11
|
+
};
|
|
12
|
+
const useViewport = () => {
|
|
13
|
+
const value = useContext(AnodeReactContext);
|
|
14
|
+
if (!value) throw new Error("useViewport must be used within an AnodeProvider");
|
|
15
|
+
return {
|
|
16
|
+
viewport: value.viewport,
|
|
17
|
+
setViewport: value.setViewport,
|
|
18
|
+
screenToWorld: value.screenToWorld
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const useSelection = () => {
|
|
22
|
+
const value = useContext(AnodeReactContext);
|
|
23
|
+
if (!value) throw new Error("useSelection must be used within an AnodeProvider");
|
|
24
|
+
return {
|
|
25
|
+
selection: value.selection,
|
|
26
|
+
setSelection: value.setSelection
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const AnodeProvider = ({ children, context }) => {
|
|
30
|
+
const [ctx] = useState(() => context ?? new Context());
|
|
31
|
+
const [viewport, setViewport] = useState({
|
|
32
|
+
x: 0,
|
|
33
|
+
y: 0,
|
|
34
|
+
k: 1
|
|
35
|
+
});
|
|
36
|
+
const [screenToWorld, setScreenToWorld] = useState(() => (x, y) => ({
|
|
37
|
+
x,
|
|
38
|
+
y
|
|
39
|
+
}));
|
|
40
|
+
const [selection, setSelection] = useState({
|
|
41
|
+
nodes: /* @__PURE__ */ new Set(),
|
|
42
|
+
links: /* @__PURE__ */ new Set()
|
|
43
|
+
});
|
|
44
|
+
const value = useMemo(() => ({
|
|
45
|
+
ctx,
|
|
46
|
+
viewport,
|
|
47
|
+
setViewport,
|
|
48
|
+
screenToWorld,
|
|
49
|
+
setScreenToWorld,
|
|
50
|
+
selection,
|
|
51
|
+
setSelection
|
|
52
|
+
}), [
|
|
53
|
+
ctx,
|
|
54
|
+
viewport,
|
|
55
|
+
screenToWorld,
|
|
56
|
+
selection
|
|
57
|
+
]);
|
|
58
|
+
return /* @__PURE__ */ jsx(AnodeReactContext.Provider, {
|
|
59
|
+
value,
|
|
60
|
+
children
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
export { AnodeProvider, AnodeReactContext, useAnode, useSelection, useViewport };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/elements/Background.d.ts
|
|
4
|
+
interface BackgroundProps {
|
|
5
|
+
color?: string;
|
|
6
|
+
size?: number;
|
|
7
|
+
gap?: number;
|
|
8
|
+
pattern?: 'dots' | 'lines';
|
|
9
|
+
}
|
|
10
|
+
declare const Background: React.FC<BackgroundProps>;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { Background };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useViewport } from "../context.js";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/elements/Background.tsx
|
|
6
|
+
const Background = ({ color = "#cbd5e1", size = 1, gap = 20, pattern = "dots" }) => {
|
|
7
|
+
const { viewport } = useViewport();
|
|
8
|
+
const scaledGap = gap * viewport.k;
|
|
9
|
+
const scaledSize = size * viewport.k;
|
|
10
|
+
const offsetX = viewport.x % scaledGap;
|
|
11
|
+
const offsetY = viewport.y % scaledGap;
|
|
12
|
+
return /* @__PURE__ */ jsx("div", { style: {
|
|
13
|
+
position: "absolute",
|
|
14
|
+
top: 0,
|
|
15
|
+
left: 0,
|
|
16
|
+
width: "100%",
|
|
17
|
+
height: "100%",
|
|
18
|
+
pointerEvents: "none",
|
|
19
|
+
zIndex: 0,
|
|
20
|
+
backgroundImage: pattern === "dots" ? `radial-gradient(${color} ${scaledSize}px, transparent 0)` : `linear-gradient(to right, ${color} ${Math.max(1, viewport.k)}px, transparent 1px), linear-gradient(to bottom, ${color} ${Math.max(1, viewport.k)}px, transparent 1px)`,
|
|
21
|
+
backgroundSize: `${scaledGap}px ${scaledGap}px`,
|
|
22
|
+
backgroundPosition: `${offsetX}px ${offsetY}px`
|
|
23
|
+
} });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { Background };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useAnode, useViewport } from "../context.js";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/elements/Controls.tsx
|
|
6
|
+
const Controls = ({ style }) => {
|
|
7
|
+
const { viewport, setViewport } = useViewport();
|
|
8
|
+
const ctx = useAnode();
|
|
9
|
+
const onZoomIn = () => {
|
|
10
|
+
setViewport({
|
|
11
|
+
...viewport,
|
|
12
|
+
k: Math.min(viewport.k * 1.2, 5)
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
const onZoomOut = () => {
|
|
16
|
+
setViewport({
|
|
17
|
+
...viewport,
|
|
18
|
+
k: Math.max(viewport.k / 1.2, .1)
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
const onFitView = () => {
|
|
22
|
+
if (ctx.entities.size === 0) {
|
|
23
|
+
setViewport({
|
|
24
|
+
x: 0,
|
|
25
|
+
y: 0,
|
|
26
|
+
k: 1
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
31
|
+
for (const entity of ctx.entities.values()) {
|
|
32
|
+
const pos = ctx.getWorldPosition(entity.id);
|
|
33
|
+
minX = Math.min(minX, pos.x);
|
|
34
|
+
minY = Math.min(minY, pos.y);
|
|
35
|
+
maxX = Math.max(maxX, pos.x);
|
|
36
|
+
maxY = Math.max(maxY, pos.y);
|
|
37
|
+
}
|
|
38
|
+
const padding = 50;
|
|
39
|
+
const w = maxX - minX + padding * 2;
|
|
40
|
+
const h = maxY - minY + padding * 2;
|
|
41
|
+
const containerW = window.innerWidth;
|
|
42
|
+
const containerH = window.innerHeight;
|
|
43
|
+
const k = Math.min(containerW / w, containerH / h, 1);
|
|
44
|
+
setViewport({
|
|
45
|
+
x: (containerW - (maxX + minX) * k) / 2,
|
|
46
|
+
y: (containerH - (maxY + minY) * k) / 2,
|
|
47
|
+
k
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
51
|
+
className: "anode-controls",
|
|
52
|
+
style: {
|
|
53
|
+
position: "absolute",
|
|
54
|
+
bottom: 20,
|
|
55
|
+
left: 20,
|
|
56
|
+
display: "flex",
|
|
57
|
+
flexDirection: "column",
|
|
58
|
+
gap: 5,
|
|
59
|
+
zIndex: 100,
|
|
60
|
+
...style
|
|
61
|
+
},
|
|
62
|
+
children: [
|
|
63
|
+
/* @__PURE__ */ jsx("button", {
|
|
64
|
+
onClick: onZoomIn,
|
|
65
|
+
style: buttonStyle,
|
|
66
|
+
children: "+"
|
|
67
|
+
}),
|
|
68
|
+
/* @__PURE__ */ jsx("button", {
|
|
69
|
+
onClick: onZoomOut,
|
|
70
|
+
style: buttonStyle,
|
|
71
|
+
children: "-"
|
|
72
|
+
}),
|
|
73
|
+
/* @__PURE__ */ jsx("button", {
|
|
74
|
+
onClick: onFitView,
|
|
75
|
+
style: buttonStyle,
|
|
76
|
+
children: "w"
|
|
77
|
+
})
|
|
78
|
+
]
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
const buttonStyle = {
|
|
82
|
+
width: 30,
|
|
83
|
+
height: 30,
|
|
84
|
+
background: "white",
|
|
85
|
+
border: "1px solid #ccc",
|
|
86
|
+
borderRadius: 4,
|
|
87
|
+
cursor: "pointer",
|
|
88
|
+
display: "flex",
|
|
89
|
+
alignItems: "center",
|
|
90
|
+
justifyContent: "center",
|
|
91
|
+
fontSize: 18,
|
|
92
|
+
fontWeight: "bold"
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
export { Controls };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useAnode, useViewport } from "../context.js";
|
|
2
|
+
import React, { useState } from "react";
|
|
3
|
+
import { Rect, Vec2 } from "@stuly/anode";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
|
|
6
|
+
//#region src/elements/Group.tsx
|
|
7
|
+
const Group = ({ id, children }) => {
|
|
8
|
+
const ctx = useAnode();
|
|
9
|
+
const { viewport } = useViewport();
|
|
10
|
+
const group = ctx.groups.get(id);
|
|
11
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
12
|
+
if (!group) return null;
|
|
13
|
+
const worldPos = ctx.getGroupWorldPosition(id);
|
|
14
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
15
|
+
const calculateBounds = (g) => {
|
|
16
|
+
for (const eid of g.entities) if (ctx.entities.get(eid)) {
|
|
17
|
+
const p = ctx.getWorldPosition(eid);
|
|
18
|
+
minX = Math.min(minX, p.x - 50);
|
|
19
|
+
minY = Math.min(minY, p.y - 50);
|
|
20
|
+
maxX = Math.max(maxX, p.x + 150);
|
|
21
|
+
maxY = Math.max(maxY, p.y + 100);
|
|
22
|
+
}
|
|
23
|
+
for (const gid of g.groups) {
|
|
24
|
+
const childGroup = ctx.groups.get(gid);
|
|
25
|
+
if (childGroup) calculateBounds(childGroup);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
calculateBounds(group);
|
|
29
|
+
const hasChildren = minX !== Infinity;
|
|
30
|
+
const width = hasChildren ? maxX - minX : 200;
|
|
31
|
+
const height = hasChildren ? maxY - minY : 200;
|
|
32
|
+
const x = hasChildren ? minX : worldPos.x;
|
|
33
|
+
const y = hasChildren ? minY : worldPos.y;
|
|
34
|
+
const onMouseDown = (e) => {
|
|
35
|
+
if (e.button !== 0) return;
|
|
36
|
+
e.stopPropagation();
|
|
37
|
+
setIsDragging(true);
|
|
38
|
+
e.clientX;
|
|
39
|
+
e.clientY;
|
|
40
|
+
let lastX = e.clientX;
|
|
41
|
+
let lastY = e.clientY;
|
|
42
|
+
const onMouseMoveDelta = (moveEvent) => {
|
|
43
|
+
const dx = (moveEvent.clientX - lastX) / viewport.k;
|
|
44
|
+
const dy = (moveEvent.clientY - lastY) / viewport.k;
|
|
45
|
+
lastX = moveEvent.clientX;
|
|
46
|
+
lastY = moveEvent.clientY;
|
|
47
|
+
ctx.moveGroup(group, dx, dy);
|
|
48
|
+
};
|
|
49
|
+
const onMouseUp = () => {
|
|
50
|
+
setIsDragging(false);
|
|
51
|
+
document.removeEventListener("mousemove", onMouseMoveDelta);
|
|
52
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
53
|
+
};
|
|
54
|
+
document.addEventListener("mousemove", onMouseMoveDelta);
|
|
55
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
56
|
+
};
|
|
57
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
58
|
+
className: "anode-group",
|
|
59
|
+
onMouseDown,
|
|
60
|
+
style: {
|
|
61
|
+
position: "absolute",
|
|
62
|
+
left: x,
|
|
63
|
+
top: y,
|
|
64
|
+
width,
|
|
65
|
+
height,
|
|
66
|
+
background: "rgba(0, 0, 0, 0.05)",
|
|
67
|
+
border: "1px dashed #ccc",
|
|
68
|
+
borderRadius: 8,
|
|
69
|
+
zIndex: -1,
|
|
70
|
+
cursor: isDragging ? "grabbing" : "grab",
|
|
71
|
+
pointerEvents: "auto"
|
|
72
|
+
},
|
|
73
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
74
|
+
style: {
|
|
75
|
+
padding: 8,
|
|
76
|
+
fontSize: 12,
|
|
77
|
+
color: "#999",
|
|
78
|
+
userSelect: "none"
|
|
79
|
+
},
|
|
80
|
+
children: group.name || `Group ${id}`
|
|
81
|
+
}), children]
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
export { Group };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "@stuly/anode";
|
|
3
|
+
|
|
4
|
+
//#region src/elements/Link.d.ts
|
|
5
|
+
interface LinkComponentProps {
|
|
6
|
+
id: number;
|
|
7
|
+
link: Link;
|
|
8
|
+
}
|
|
9
|
+
interface LinkProps {
|
|
10
|
+
id: number;
|
|
11
|
+
style?: React.CSSProperties;
|
|
12
|
+
component?: React.ComponentType<LinkComponentProps> | undefined;
|
|
13
|
+
}
|
|
14
|
+
declare const Link$1: React.FC<LinkProps>;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { Link$1 as Link, LinkComponentProps };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useAnode, useSelection, useViewport } from "../context.js";
|
|
2
|
+
import React, { useEffect, useState } from "react";
|
|
3
|
+
import { Link, Vec2, getLinkCenter, getLinkPath, getLinkPoints } from "@stuly/anode";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
|
|
6
|
+
//#region src/elements/Link.tsx
|
|
7
|
+
const Link$1 = ({ id, style, component: Component }) => {
|
|
8
|
+
const ctx = useAnode();
|
|
9
|
+
const { viewport, screenToWorld } = useViewport();
|
|
10
|
+
const { selection, setSelection } = useSelection();
|
|
11
|
+
const link = ctx.links.get(id);
|
|
12
|
+
const [, setTick] = useState(0);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!link) return;
|
|
15
|
+
const onUpdate = () => setTick((t) => t + 1);
|
|
16
|
+
const h1 = ctx.registerEntityMoveListener(onUpdate);
|
|
17
|
+
const h2 = ctx.registerSocketMoveListener(onUpdate);
|
|
18
|
+
const h3 = ctx.registerSocketCreateListener(onUpdate);
|
|
19
|
+
const h4 = ctx.registerLinkUpdateListener((l) => {
|
|
20
|
+
if (l.id === id) onUpdate();
|
|
21
|
+
});
|
|
22
|
+
return () => {
|
|
23
|
+
ctx.unregisterListener(h1);
|
|
24
|
+
ctx.unregisterListener(h2);
|
|
25
|
+
ctx.unregisterListener(h3);
|
|
26
|
+
ctx.unregisterListener(h4);
|
|
27
|
+
};
|
|
28
|
+
}, [
|
|
29
|
+
ctx,
|
|
30
|
+
link,
|
|
31
|
+
id
|
|
32
|
+
]);
|
|
33
|
+
if (!link) return null;
|
|
34
|
+
const d = getLinkPath(ctx, link);
|
|
35
|
+
const pts = getLinkPoints(ctx, link);
|
|
36
|
+
const center = getLinkCenter(ctx, link);
|
|
37
|
+
if (!d || !pts) return null;
|
|
38
|
+
const isSelected = selection.links.has(id);
|
|
39
|
+
const onClick = (e) => {
|
|
40
|
+
e.stopPropagation();
|
|
41
|
+
if (e.shiftKey) setSelection((prev) => {
|
|
42
|
+
const next = new Set(prev.links);
|
|
43
|
+
if (next.has(id)) next.delete(id);
|
|
44
|
+
else next.add(id);
|
|
45
|
+
return {
|
|
46
|
+
...prev,
|
|
47
|
+
links: next
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
else setSelection({
|
|
51
|
+
nodes: /* @__PURE__ */ new Set(),
|
|
52
|
+
links: new Set([id])
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const onDoubleClick = (e) => {
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
const { x, y } = screenToWorld(e.clientX, e.clientY);
|
|
58
|
+
const newWaypoints = [...link.waypoints, new Vec2(x, y)];
|
|
59
|
+
ctx.setLinkWaypoints(link, newWaypoints);
|
|
60
|
+
};
|
|
61
|
+
const onHandleMouseDown = (e, type) => {
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
const event = new CustomEvent("anode-link-reconnect", {
|
|
64
|
+
bubbles: true,
|
|
65
|
+
detail: {
|
|
66
|
+
linkId: id,
|
|
67
|
+
type,
|
|
68
|
+
x: e.clientX,
|
|
69
|
+
y: e.clientY
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
e.target.dispatchEvent(event);
|
|
73
|
+
};
|
|
74
|
+
const onWaypointMouseDown = (e, index) => {
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
const startX = e.clientX;
|
|
77
|
+
const startY = e.clientY;
|
|
78
|
+
const initialWaypoints = link.waypoints.map((p) => p.clone());
|
|
79
|
+
const onMouseMove = (moveEvent) => {
|
|
80
|
+
const dx = (moveEvent.clientX - startX) / viewport.k;
|
|
81
|
+
const dy = (moveEvent.clientY - startY) / viewport.k;
|
|
82
|
+
link.waypoints = initialWaypoints.map((p, i) => {
|
|
83
|
+
if (i === index) return new Vec2(p.x + dx, p.y + dy);
|
|
84
|
+
return p;
|
|
85
|
+
});
|
|
86
|
+
setTick((t) => t + 1);
|
|
87
|
+
};
|
|
88
|
+
const onMouseUp = () => {
|
|
89
|
+
document.removeEventListener("mousemove", onMouseMove);
|
|
90
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
91
|
+
ctx.setLinkWaypoints(link, link.waypoints);
|
|
92
|
+
};
|
|
93
|
+
document.addEventListener("mousemove", onMouseMove);
|
|
94
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
95
|
+
};
|
|
96
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("g", {
|
|
97
|
+
onClick,
|
|
98
|
+
onDoubleClick,
|
|
99
|
+
style: { cursor: "pointer" },
|
|
100
|
+
children: [
|
|
101
|
+
/* @__PURE__ */ jsx("path", {
|
|
102
|
+
d,
|
|
103
|
+
fill: "none",
|
|
104
|
+
stroke: "transparent",
|
|
105
|
+
strokeWidth: 15
|
|
106
|
+
}),
|
|
107
|
+
/* @__PURE__ */ jsx("path", {
|
|
108
|
+
d,
|
|
109
|
+
fill: "none",
|
|
110
|
+
stroke: isSelected ? "#3b82f6" : "#94a3b8",
|
|
111
|
+
strokeWidth: isSelected ? 3 : 2,
|
|
112
|
+
style: {
|
|
113
|
+
transition: "stroke 0.2s, stroke-width 0.2s",
|
|
114
|
+
...style
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
isSelected && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
118
|
+
/* @__PURE__ */ jsx("circle", {
|
|
119
|
+
cx: pts.from.x,
|
|
120
|
+
cy: pts.from.y,
|
|
121
|
+
r: 5,
|
|
122
|
+
fill: "white",
|
|
123
|
+
stroke: "#3b82f6",
|
|
124
|
+
strokeWidth: 2,
|
|
125
|
+
onMouseDown: (e) => onHandleMouseDown(e, "from"),
|
|
126
|
+
style: {
|
|
127
|
+
cursor: "crosshair",
|
|
128
|
+
pointerEvents: "auto"
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
131
|
+
/* @__PURE__ */ jsx("circle", {
|
|
132
|
+
cx: pts.to.x,
|
|
133
|
+
cy: pts.to.y,
|
|
134
|
+
r: 5,
|
|
135
|
+
fill: "white",
|
|
136
|
+
stroke: "#3b82f6",
|
|
137
|
+
strokeWidth: 2,
|
|
138
|
+
onMouseDown: (e) => onHandleMouseDown(e, "to"),
|
|
139
|
+
style: {
|
|
140
|
+
cursor: "crosshair",
|
|
141
|
+
pointerEvents: "auto"
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
link.waypoints.map((p, i) => /* @__PURE__ */ jsx("circle", {
|
|
145
|
+
cx: p.x,
|
|
146
|
+
cy: p.y,
|
|
147
|
+
r: 4,
|
|
148
|
+
fill: "#3b82f6",
|
|
149
|
+
onMouseDown: (e) => onWaypointMouseDown(e, i),
|
|
150
|
+
onContextMenu: (e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
const newWaypoints = link.waypoints.filter((_, idx) => idx !== i);
|
|
154
|
+
ctx.setLinkWaypoints(link, newWaypoints);
|
|
155
|
+
},
|
|
156
|
+
style: {
|
|
157
|
+
cursor: "move",
|
|
158
|
+
pointerEvents: "auto"
|
|
159
|
+
}
|
|
160
|
+
}, i)),
|
|
161
|
+
" "
|
|
162
|
+
] })
|
|
163
|
+
]
|
|
164
|
+
}), Component && center && /* @__PURE__ */ jsx("foreignObject", {
|
|
165
|
+
x: center.x - 50,
|
|
166
|
+
y: center.y - 25,
|
|
167
|
+
width: 100,
|
|
168
|
+
height: 50,
|
|
169
|
+
style: {
|
|
170
|
+
overflow: "visible",
|
|
171
|
+
pointerEvents: "none"
|
|
172
|
+
},
|
|
173
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
174
|
+
style: {
|
|
175
|
+
width: "100%",
|
|
176
|
+
height: "100%",
|
|
177
|
+
display: "flex",
|
|
178
|
+
alignItems: "center",
|
|
179
|
+
justifyContent: "center",
|
|
180
|
+
pointerEvents: "auto"
|
|
181
|
+
},
|
|
182
|
+
children: /* @__PURE__ */ jsx(Component, {
|
|
183
|
+
id,
|
|
184
|
+
link
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
})] });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
export { Link$1 as Link };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useAnode, useViewport } from "../context.js";
|
|
2
|
+
import React, { useMemo } from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/elements/MiniMap.tsx
|
|
6
|
+
const MiniMap = ({ width = 200, height = 150, style }) => {
|
|
7
|
+
const ctx = useAnode();
|
|
8
|
+
const { viewport } = useViewport();
|
|
9
|
+
const bounds = useMemo(() => {
|
|
10
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
11
|
+
if (ctx.entities.size === 0) return {
|
|
12
|
+
x: 0,
|
|
13
|
+
y: 0,
|
|
14
|
+
w: 1e3,
|
|
15
|
+
h: 1e3
|
|
16
|
+
};
|
|
17
|
+
for (const entity of ctx.entities.values()) {
|
|
18
|
+
const pos = ctx.getWorldPosition(entity.id);
|
|
19
|
+
minX = Math.min(minX, pos.x);
|
|
20
|
+
minY = Math.min(minY, pos.y);
|
|
21
|
+
maxX = Math.max(maxX, pos.x);
|
|
22
|
+
maxY = Math.max(maxY, pos.y);
|
|
23
|
+
}
|
|
24
|
+
const padding = 100;
|
|
25
|
+
minX -= padding;
|
|
26
|
+
minY -= padding;
|
|
27
|
+
maxX += padding;
|
|
28
|
+
maxY += padding;
|
|
29
|
+
return {
|
|
30
|
+
x: minX,
|
|
31
|
+
y: minY,
|
|
32
|
+
w: maxX - minX,
|
|
33
|
+
h: maxY - minY
|
|
34
|
+
};
|
|
35
|
+
}, [ctx.entities.size, Array.from(ctx.entities.values()).map((e) => e.position.x + e.position.y).join(",")]);
|
|
36
|
+
const scale = Math.min(width / bounds.w, height / bounds.h);
|
|
37
|
+
return /* @__PURE__ */ jsx("div", {
|
|
38
|
+
className: "anode-minimap",
|
|
39
|
+
style: {
|
|
40
|
+
position: "absolute",
|
|
41
|
+
bottom: 20,
|
|
42
|
+
right: 20,
|
|
43
|
+
width,
|
|
44
|
+
height,
|
|
45
|
+
background: "rgba(255, 255, 255, 0.8)",
|
|
46
|
+
border: "1px solid #ccc",
|
|
47
|
+
borderRadius: 4,
|
|
48
|
+
overflow: "hidden",
|
|
49
|
+
pointerEvents: "none",
|
|
50
|
+
zIndex: 100,
|
|
51
|
+
...style
|
|
52
|
+
},
|
|
53
|
+
children: /* @__PURE__ */ jsxs("svg", {
|
|
54
|
+
width,
|
|
55
|
+
height,
|
|
56
|
+
viewBox: `${bounds.x} ${bounds.y} ${bounds.w} ${bounds.h}`,
|
|
57
|
+
children: [Array.from(ctx.entities.values()).map((entity) => {
|
|
58
|
+
const pos = ctx.getWorldPosition(entity.id);
|
|
59
|
+
return /* @__PURE__ */ jsx("rect", {
|
|
60
|
+
x: pos.x - 25,
|
|
61
|
+
y: pos.y - 25,
|
|
62
|
+
width: 50,
|
|
63
|
+
height: 50,
|
|
64
|
+
fill: "#94a3b8"
|
|
65
|
+
}, entity.id);
|
|
66
|
+
}), /* @__PURE__ */ jsx("rect", {
|
|
67
|
+
x: -viewport.x / viewport.k,
|
|
68
|
+
y: -viewport.y / viewport.k,
|
|
69
|
+
width: width / (scale * viewport.k),
|
|
70
|
+
height: height / (scale * viewport.k),
|
|
71
|
+
fill: "rgba(59, 130, 246, 0.1)",
|
|
72
|
+
stroke: "#3b82f6",
|
|
73
|
+
strokeWidth: 2 / scale
|
|
74
|
+
})]
|
|
75
|
+
})
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
export { MiniMap };
|