feat: renders a basic node to scene

This commit is contained in:
Mark Mankarious
2023-03-28 23:09:59 +01:00
parent 611381934f
commit 5dbeb973b0
21 changed files with 541 additions and 98 deletions

View File

@@ -43,7 +43,7 @@ import Isoflow from 'isoflow';
const App = () => (
<Isoflow
height={500}
initialScene={{
scene={{
icons: [],
nodes: [],
connectors: [],

36
package-lock.json generated
View File

@@ -14,6 +14,8 @@
"@mui/icons-material": "^5.11.9",
"@mui/material": "^5.11.10",
"auto-bind": "^5.0.1",
"cuid": "^3.0.0",
"deep-diff": "^1.0.2",
"gsap": "^3.11.4",
"mobx": "^6.8.0",
"mobx-react": "^7.6.0",
@@ -27,6 +29,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/deep-diff": "^1.0.2",
"@types/jest": "^27.5.2",
"@types/jsdom": "^21.1.0",
"@types/node": "^16.18.12",
@@ -2327,6 +2330,12 @@
"@types/node": "*"
}
},
"node_modules/@types/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-WD2O611C7Oz7RSwKbSls8LaznKfWfXh39CHY9Amd8FhQz+NJRe20nUHhYpOopVq9M2oqDZd4L6AzqJIXQycxiA==",
"dev": true
},
"node_modules/@types/eslint": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.1.tgz",
@@ -3982,6 +3991,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
},
"node_modules/cuid": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cuid/-/cuid-3.0.0.tgz",
"integrity": "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg==",
"deprecated": "Cuid and other k-sortable and non-cryptographic ids (Ulid, ObjectId, KSUID, all UUIDs) are all insecure. Use @paralleldrive/cuid2 instead."
},
"node_modules/data-urls": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz",
@@ -4038,6 +4053,11 @@
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
"dev": true
},
"node_modules/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg=="
},
"node_modules/deep-equal": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz",
@@ -13141,6 +13161,12 @@
"@types/node": "*"
}
},
"@types/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-WD2O611C7Oz7RSwKbSls8LaznKfWfXh39CHY9Amd8FhQz+NJRe20nUHhYpOopVq9M2oqDZd4L6AzqJIXQycxiA==",
"dev": true
},
"@types/eslint": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.1.tgz",
@@ -14490,6 +14516,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
},
"cuid": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cuid/-/cuid-3.0.0.tgz",
"integrity": "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="
},
"data-urls": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz",
@@ -14528,6 +14559,11 @@
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
"dev": true
},
"deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg=="
},
"deep-equal": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz",

View File

@@ -20,6 +20,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/deep-diff": "^1.0.2",
"@types/jest": "^27.5.2",
"@types/jsdom": "^21.1.0",
"@types/node": "^16.18.12",
@@ -47,6 +48,8 @@
"@mui/icons-material": "^5.11.9",
"@mui/material": "^5.11.10",
"auto-bind": "^5.0.1",
"cuid": "^3.0.0",
"deep-diff": "^1.0.2",
"gsap": "^3.11.4",
"mobx": "^6.8.0",
"mobx-react": "^7.6.0",

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import React, { useEffect, useMemo } from "react";
import { ThemeProvider } from "@mui/material/styles";
import Box from "@mui/material/Box";
import { theme } from "./theme";
@@ -9,41 +10,56 @@ import { RendererContainer } from "./components/RendererContainer";
import { SceneI } from "./validation/SceneSchema";
import { ModeManagerProvider } from "./contexts/ModeManagerContext";
import { useGlobalState } from "./hooks/useGlobalState";
import { OnSceneChange } from "./renderer/types";
interface Props {
initialScene: SceneI;
onSceneChange: OnSceneChange;
width?: number | string;
height: number | string;
}
const App = ({ initialScene, width, height }: Props) => {
const setInitialScene = useGlobalState((state) => state.setInitialScene);
const InnerApp = React.memo(
({ height, width }: Pick<Props, "height" | "width">) => {
return (
<ThemeProvider theme={theme}>
<ModeManagerProvider>
<Box
sx={{
width: width ?? "100%",
height,
position: "relative",
overflow: "hidden",
}}
>
<RendererContainer />
<Sidebar />
<SideNav />
<ToolMenu />
</Box>
</ModeManagerProvider>
</ThemeProvider>
);
}
);
useEffect(() => {
setInitialScene(initialScene);
}, [initialScene]);
const App = observer(
({ initialScene, width, height, onSceneChange }: Props) => {
const setInitialScene = useGlobalState((state) => state.setInitialScene);
const setOnSceneChange = useGlobalState((state) => state.setOnSceneChange);
return (
<ThemeProvider theme={theme}>
<ModeManagerProvider>
<Box
sx={{
width: width ?? "100%",
height,
position: "relative",
overflow: "hidden",
}}
>
<RendererContainer />
<Sidebar />
<SideNav />
<ToolMenu />
</Box>
</ModeManagerProvider>
</ThemeProvider>
);
};
useEffect(() => {
setOnSceneChange(onSceneChange);
}, [setOnSceneChange, onSceneChange]);
useEffect(() => {
setInitialScene(initialScene);
}, [initialScene, setInitialScene]);
return <InnerApp height={height} width={width} />;
}
);
type Scene = SceneI;
export { Scene };
export { Scene, OnSceneChange };
export default App;

View File

@@ -12,11 +12,12 @@ export const RendererContainer = observer(() => {
const rendererEl = useRef<HTMLDivElement>(null);
const { setDomEl, setCallbacks } = useMouseInput();
const setRenderer = useGlobalState((state) => state.setRenderer);
const onSceneChange = useGlobalState((state) => state.onSceneChange);
useEffect(() => {
if (!rendererEl.current) return;
const renderer = new Renderer(rendererEl.current);
const renderer = new Renderer(rendererEl.current, onSceneChange);
setRenderer(renderer);
setDomEl(rendererEl.current);
modeManager.setRenderer(renderer);
@@ -39,7 +40,7 @@ export const RendererContainer = observer(() => {
modeManager.onMouseEvent("MOUSE_LEAVE", event);
},
});
}, [setRenderer, setDomEl, modeManager]);
}, [setRenderer, setDomEl, modeManager, onSceneChange]);
return (
<div

View File

@@ -9,13 +9,11 @@ import { useGlobalState } from "../../hooks/useGlobalState";
export const Sidebar = () => {
const theme = useTheme();
const { selectedSideNavItem, closeSideNav, icons } = useGlobalState(
(state) => ({
selectedSideNavItem: state.selectedSideNavItem,
closeSideNav: state.closeSideNav,
icons: state.initialScene.icons,
})
const selectedSideNavItem = useGlobalState(
(state) => state.selectedSideNavItem
);
const closeSideNav = useGlobalState((state) => state.closeSideNav);
const icons = useGlobalState((state) => state.renderer.config.icons);
return (
<Slide

View File

@@ -1,8 +1,12 @@
import { create } from "zustand";
import { SceneI } from "../validation/SceneSchema";
import { Renderer } from "../renderer/Renderer";
import { OnSceneChange } from "../renderer/types";
import { getRandom } from "../utils";
interface GlobalState {
onSceneChange: OnSceneChange;
setOnSceneChange: (onSceneChange: OnSceneChange) => void;
initialScene: SceneI;
setInitialScene: (scene: SceneI) => void;
selectedSideNavItem: number | null;
@@ -12,12 +16,14 @@ interface GlobalState {
setRenderer: (renderer: Renderer) => void;
}
export const useGlobalState = create<GlobalState>((set) => ({
export const useGlobalState = create<GlobalState>((set, get) => ({
onSceneChange: () => {},
setOnSceneChange: (onSceneChange) => set({ onSceneChange }),
initialScene: {
icons: [],
nodes: [],
groups: [],
connectors: [],
groups: [],
},
setInitialScene: (scene) => {
set({ initialScene: scene });
@@ -29,15 +35,23 @@ export const useGlobalState = create<GlobalState>((set) => ({
closeSideNav: () => {
set({ selectedSideNavItem: null });
},
renderer: new Renderer(document.createElement("div")),
renderer: new Renderer(document.createElement("div"), () => {}),
setRenderer: (renderer: Renderer) =>
set((state) => {
if (state.renderer) {
state.renderer.destroy();
}
const scene = state.initialScene;
renderer.loadScene(scene);
renderer.setZoom(state.renderer.zoom);
// setInterval(() => {
// const node = renderer.sceneElements.nodes.getNodeById("Node1");
// node?.moveTo(getRandom(-5, 5), getRandom(-5, 5));
// }, 1000);
return { renderer };
}),
}));

View File

@@ -1,22 +1,37 @@
import React from "react";
import React, { useCallback } from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import GlobalStyles from "@mui/material/GlobalStyles";
import { mockScene } from "./mockData";
import { OnSceneChange } from "./renderer/types";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
const DataLayer = () => {
const onSceneChange = useCallback<OnSceneChange>((event, scene) => {}, []);
return (
<>
<GlobalStyles
styles={{
body: {
margin: 0,
},
}}
/>
<App
initialScene={mockScene}
onSceneChange={onSceneChange}
height="100vh"
/>
</>
);
};
root.render(
<React.StrictMode>
<GlobalStyles
styles={{
body: {
margin: 0,
},
}}
/>
<App initialScene={mockScene} height="100vh" />
<DataLayer />
</React.StrictMode>
);

View File

@@ -1,7 +1,6 @@
import type { Icon } from "./types";
import type { SceneI } from "./validation/SceneSchema";
import type { SceneI, IconI, NodeI } from "./validation/SceneSchema";
export const mockIcons: Icon[] = [
export const icons: IconI[] = [
{
id: "block",
name: "Block",
@@ -51,9 +50,21 @@ export const mockIcons: Icon[] = [
},
];
export const nodes: NodeI[] = [
{
id: "Node1",
label: "Node 1",
icon: "block",
position: {
x: 0,
y: 0,
},
},
];
export const mockScene: SceneI = {
icons: mockIcons,
nodes: [],
icons,
nodes,
connectors: [],
groups: [],
};

View File

@@ -1,19 +1,39 @@
import { makeAutoObservable } from "mobx";
import Paper, { Group } from "paper";
import gsap from "gsap";
import autobind from "auto-bind";
import { Grid } from "./elements/Grid";
import { Cursor } from "./elements/Cursor";
import { PROJECTED_TILE_WIDTH, PROJECTED_TILE_HEIGHT } from "./constants";
import { clamp } from "../utils";
import { Nodes } from "./elements/Nodes";
import { SceneI, IconI } from "../validation/SceneSchema";
import { OnSceneChange } from "./types";
import { createSceneEvent, SceneEvent } from "./SceneEvent";
import { mockScene } from "../mockData";
interface Config {
grid: {
width: number;
height: number;
};
icons: IconI[];
}
export class Renderer {
activeLayer: paper.Layer;
zoom: number = 1;
zoom = 1;
config = {
config: Config = {
grid: {
width: 51,
height: 51,
},
icons: [],
};
createSceneEvent: ReturnType<typeof createSceneEvent>;
callbacks: {
onSceneChange: OnSceneChange;
};
groups: {
container: paper.Group;
@@ -22,6 +42,7 @@ export class Renderer {
sceneElements: {
grid: Grid;
cursor: Cursor;
nodes: Nodes;
};
domElements: {
container: HTMLDivElement;
@@ -33,7 +54,16 @@ export class Renderer {
};
rafRef?: number;
constructor(containerEl: HTMLDivElement) {
constructor(containerEl: HTMLDivElement, onChange: OnSceneChange) {
makeAutoObservable(this);
autobind(this);
this.createSceneEvent = createSceneEvent(this.onSceneChange);
this.callbacks = {
onSceneChange: onChange,
};
Paper.settings = {
insertelements: false,
applyMatrix: false,
@@ -47,8 +77,9 @@ export class Renderer {
Paper.setup(this.domElements.canvas);
this.sceneElements = {
grid: new Grid(this.config),
cursor: new Cursor(this.config),
grid: new Grid(this),
cursor: new Cursor(this),
nodes: new Nodes(this),
};
this.groups = {
@@ -58,6 +89,7 @@ export class Renderer {
this.groups.elements.addChild(this.sceneElements.grid.container);
this.groups.elements.addChild(this.sceneElements.cursor.container);
this.groups.elements.addChild(this.sceneElements.nodes.container);
this.groups.container.addChild(this.groups.elements);
this.groups.container.set({ position: [0, 0] });
@@ -68,6 +100,30 @@ export class Renderer {
this.scrollTo(0, 0);
this.render();
this.init();
}
init() {}
loadScene(scene: SceneI) {
const sceneEvent = this.createSceneEvent({
type: "SCENE_LOAD",
});
this.config.icons = scene.icons;
scene.nodes.forEach((node) => {
this.sceneElements.nodes.addNode(
{
...node,
icon: mockScene.icons[0],
},
sceneEvent
);
});
sceneEvent.complete();
}
initDOM(containerEl: HTMLDivElement) {
@@ -104,6 +160,34 @@ export class Renderer {
};
}
getTilePosition(x: number, y: number) {
const halfW = PROJECTED_TILE_WIDTH * 0.5;
const halfH = PROJECTED_TILE_HEIGHT * 0.5;
return {
x: x * halfW - y * halfW,
y: x * halfH + y * halfH,
};
}
getTileBounds(x: number, y: number) {
const position = this.getTilePosition(x, y);
return {
left: {
x: position.x - PROJECTED_TILE_WIDTH * 0.5,
y: position.y - PROJECTED_TILE_HEIGHT * 0.5,
},
right: {
x: position.x + PROJECTED_TILE_WIDTH * 0.5,
y: position.y - PROJECTED_TILE_HEIGHT * 0.5,
},
top: { x: position.x, y: position.y - PROJECTED_TILE_HEIGHT },
bottom: { x: position.x, y: position.y },
center: { x: position.x, y: position.y - PROJECTED_TILE_HEIGHT * 0.5 },
};
}
setGrid(width: number, height: number) {}
setZoom(zoom: number) {
@@ -139,6 +223,10 @@ export class Renderer {
);
}
clear() {
this.sceneElements.nodes.clear();
}
destroy() {
this.domElements.canvas.remove();
@@ -154,4 +242,23 @@ export class Renderer {
Paper.view.update();
}
}
exportScene() {
const exported = {
icons: this.config.icons,
nodes: this.sceneElements.nodes.export(),
groups: [],
connectors: [],
};
return exported;
}
onSceneChange(sceneEvent: SceneEvent) {
this.callbacks.onSceneChange(sceneEvent.event, this.exportScene());
}
get nodes() {
return this.sceneElements.nodes;
}
}

View File

@@ -0,0 +1,22 @@
import { Group } from "paper";
import { Context } from "./types";
export class SceneElement {
container = new Group();
ctx: Context;
constructor(ctx: Context) {
this.ctx = ctx;
}
clear() {
this.container.removeChildren();
}
destroy() {
this.clear();
this.container.remove();
}
export() {}
}

View File

@@ -0,0 +1,43 @@
import cuid from "cuid";
import { SceneEventI } from "./types";
type OnSceneEventComplete = (event: SceneEvent) => void;
interface SceneEventArgs {
onComplete?: OnSceneEventComplete;
parentEvent?: SceneEvent;
}
export class SceneEvent {
id = cuid();
timeStarted = Date.now();
timeCompleted?: number;
event: SceneEventI;
cascadedEvents: SceneEventI[] = [];
onComplete?: (event: SceneEvent) => void;
parentEvent?: SceneEvent;
constructor(event: SceneEventI, opts: SceneEventArgs) {
this.event = event;
this.onComplete = opts.onComplete;
this.parentEvent = opts.parentEvent;
}
attachEvent(event: SceneEventI) {
this.cascadedEvents.push(event);
}
complete() {
if (this.parentEvent) this.parentEvent.attachEvent(this.event);
if (!this.parentEvent) this.onComplete?.(this);
this.timeCompleted = Date.now();
}
}
export const createSceneEvent =
(onComplete: OnSceneEventComplete) =>
(event: SceneEventI, opts?: SceneEventArgs) => {
return new SceneEvent(event, { ...opts, onComplete });
};

View File

@@ -1,4 +1,4 @@
import { Group, Shape, Point } from "paper";
import { Shape, Point } from "paper";
import { gsap } from "gsap";
import { applyProjectionMatrix } from "../utils/projection";
import { TILE_SIZE, PIXEL_UNIT } from "../constants";
@@ -7,7 +7,8 @@ import {
getBoundingBox,
getTileBounds,
} from "../utils/gridHelpers";
import type { SceneElement, Context, Coords } from "../types";
import type { Context, Coords } from "../types";
import { SceneElement } from "../SceneElement";
export enum CURSOR_TYPES {
OUTLINE = "OUTLINE",
@@ -17,12 +18,9 @@ export enum CURSOR_TYPES {
DOT = "DOT",
}
export class Cursor implements SceneElement {
ctx: Context;
container = new Group();
export class Cursor extends SceneElement {
renderElements = {
rectangle: new Shape.Rectangle({}),
rectangle: new Shape.Rectangle([0, 0]),
};
animations: {
@@ -42,7 +40,9 @@ export class Cursor implements SceneElement {
currentType?: CURSOR_TYPES;
constructor(ctx: Context) {
this.ctx = ctx;
super(ctx);
this.renderElements.rectangle = new Shape.Rectangle({});
this.animations = {
highlight: gsap

View File

@@ -1,23 +1,22 @@
import { Group, Path, Point } from "paper";
import { applyProjectionMatrix } from "../utils/projection";
import type { SceneElement, Context } from "../types";
import type { Context } from "../types";
import { TILE_SIZE, PIXEL_UNIT, SCALING_CONST } from "../constants";
import { SceneElement } from "../SceneElement";
export class Grid implements SceneElement {
ctx: Context;
export class Grid extends SceneElement {
container = new Group();
renderElements = {
grid: new Group({ applyMatrix: true }),
};
constructor(ctx: Context) {
this.ctx = ctx;
super(ctx);
this.container.addChild(this.renderElements.grid);
for (let x = 0; x <= this.ctx.grid.width; x++) {
const lineLength = this.ctx.grid.height * TILE_SIZE;
for (let x = 0; x <= this.ctx.config.grid.width; x++) {
const lineLength = this.ctx.config.grid.height * TILE_SIZE;
const start = x * TILE_SIZE - lineLength * 0.5;
const line = new Path({
segments: [
@@ -31,8 +30,8 @@ export class Grid implements SceneElement {
this.renderElements.grid.addChild(line);
}
for (let y = 0; y <= this.ctx.grid.height; y++) {
const lineLength = this.ctx.grid.width * TILE_SIZE;
for (let y = 0; y <= this.ctx.config.grid.height; y++) {
const lineLength = this.ctx.config.grid.width * TILE_SIZE;
const start = y * TILE_SIZE - lineLength * 0.5;
const line = new Path({
segments: [

View File

@@ -0,0 +1,91 @@
import { makeAutoObservable } from "mobx";
import { Group, Raster } from "paper";
import { Coords, Context } from "../types";
import { IconI } from "../../validation/SceneSchema";
import { PROJECTED_TILE_WIDTH, PIXEL_UNIT } from "../constants";
const NODE_IMG_PADDING = 0 * PIXEL_UNIT;
export interface NodeOptions {
id: string;
position: Coords;
icon: IconI;
}
interface Callbacks {
onMove: (x: number, y: number, node: Node) => void;
}
export class Node {
ctx: Context;
container = new Group();
id;
callbacks: Callbacks;
position;
icon;
renderElements = {
iconContainer: new Group(),
icon: new Raster(),
};
constructor(ctx: Context, options: NodeOptions, callbacks: Callbacks) {
makeAutoObservable(this);
this.ctx = ctx;
this.id = options.id;
this.position = options.position;
this.icon = options.icon;
this.callbacks = callbacks;
this.renderElements.iconContainer.addChild(this.renderElements.icon);
this.container.addChild(this.renderElements.iconContainer);
this.init();
}
async init() {
await this.updateIcon(this.icon);
this.moveTo(this.position.x, this.position.y);
}
async updateIcon(icon: IconI) {
this.icon = icon;
const { iconContainer, icon: iconEl } = this.renderElements;
await new Promise((resolve) => {
iconEl.onLoad = () => {
iconEl.scale(
(PROJECTED_TILE_WIDTH - NODE_IMG_PADDING) / iconEl.bounds.width
);
iconContainer.pivot = iconEl.bounds.bottomCenter;
iconContainer.position.set(0, 0);
resolve(null);
};
iconEl.source = this.icon.url;
});
}
moveTo(x: number, y: number) {
this.callbacks.onMove(x, y, this);
}
export() {
return {
id: this.id,
position: this.position,
icon: this.icon.id,
};
}
clear() {
this.container.removeChildren();
}
destroy() {
this.container.remove();
}
}

View File

@@ -0,0 +1,66 @@
import { Group } from "paper";
import autobind from "auto-bind";
import { makeAutoObservable } from "mobx";
import { Context } from "../types";
import { Node, NodeOptions } from "./Node";
import cuid from "cuid";
import { SceneElement } from "../SceneElement";
import { SceneEvent } from "../SceneEvent";
export class Nodes {
ctx: Context;
container = new Group();
nodes: Node[] = [];
constructor(ctx: Context) {
makeAutoObservable(this);
autobind(this);
this.ctx = ctx;
}
getNodeById(id: string) {
return this.nodes.find((node) => node.id === id);
}
addNode(options: NodeOptions, sceneEvent?: SceneEvent) {
const node = new Node(
this.ctx,
{
...options,
id: options.id ?? cuid(),
},
{
onMove: this.onMove.bind(this),
}
);
this.nodes.push(node);
this.container.addChild(node.container);
this.ctx
.createSceneEvent(
{
type: "NODE_CREATED",
node,
},
sceneEvent
)
.complete();
}
onMove(x: number, y: number, node: Node) {
const tile = this.ctx.getTileBounds(x, y);
node.container.position.set(tile.bottom);
}
clear() {
this.nodes.forEach((node) => node.destroy());
this.nodes = [];
}
export() {
const exported = this.nodes.map((node) => node.export());
return exported;
}
}

View File

@@ -0,0 +1,15 @@
import { Shape, Group } from "paper";
export class Positioner {
container = new Group();
constructor(color: string = "red") {
this.container.addChild(
new Shape.Circle({
center: [0, 0],
radius: 10,
fillColor: color,
})
);
}
}

View File

@@ -1,19 +1,27 @@
import type { Renderer } from "./Renderer";
import { SceneI } from "../validation/SceneSchema";
import type { Node } from "./elements/Node";
export interface Coords {
x: number;
y: number;
}
export interface Context {
grid: {
width: number;
height: number;
};
}
export type NodeEventI =
| {
type: "SCENE_LOAD";
}
| {
type: "NODE_CREATED";
node: Node;
}
| {
type: "NODE_REMOVED";
node: Node;
};
export interface SceneElement {
container: paper.Group;
ctx: Context;
renderElements?: {
[k: string]: paper.Item;
};
}
export type SceneEventI = NodeEventI;
export type Context = Renderer;
export type OnSceneChange = (event: SceneEventI, scene: SceneI) => void;

View File

@@ -1,10 +0,0 @@
export interface Icon {
id: string;
name: string;
url: string;
category?: string;
}
export interface IsoflowProps {
icons: Icon[];
}

View File

@@ -1,3 +1,7 @@
export const clamp = (num: number, min: number, max: number) => {
return num <= min ? min : num >= max ? max : num;
};
export const getRandom = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min) + min);
};

View File

@@ -11,6 +11,10 @@ export const NodeSchema = z.object({
id: z.string(),
label: z.string().optional(),
icon: z.string(),
position: z.object({
x: z.number(),
y: z.number(),
}),
});
export const ConnectorSchema = z.object({