Canvas: Add new nodes at random positions
Change-Id: I17ba195065bf8c2f7d1eea2091793766f0e0ac65
diff --git a/apps/canvas/front/src/components/canvas.tsx b/apps/canvas/front/src/components/canvas.tsx
index 8ab4aaa..252c1b9 100644
--- a/apps/canvas/front/src/components/canvas.tsx
+++ b/apps/canvas/front/src/components/canvas.tsx
@@ -8,8 +8,9 @@
Edge,
useReactFlow,
Panel,
+ useStoreApi,
} from "@xyflow/react";
-import { useStateStore, AppState, AppNode, useEnv } from "@/lib/state";
+import { useStateStore, AppState, AppNode } from "@/lib/state";
import { useShallow } from "zustand/react/shallow";
import { useCallback, useEffect, useMemo } from "react";
import { NodeGatewayHttps } from "@/components/node-gateway-https";
@@ -33,7 +34,17 @@
export function Canvas() {
const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useStateStore(useShallow(selector));
const store = useStateStore();
- const flow = useReactFlow();
+ const instance = useReactFlow();
+ const flow = useStoreApi();
+ useEffect(() => {
+ store.setViewport({
+ width: flow.getState().width,
+ height: flow.getState().height,
+ transformX: flow.getState().transform[0],
+ transformY: flow.getState().transform[1],
+ transformZoom: flow.getState().transform[2],
+ });
+ }, [store, flow]);
const nodeTypes = useMemo(
() => ({
network: NodeNetwork,
@@ -52,8 +63,8 @@
if (c.source === c.target) {
return false;
}
- const sn = flow.getNode(c.source)! as AppNode;
- const tn = flow.getNode(c.target)! as AppNode;
+ const sn = instance.getNode(c.source)! as AppNode;
+ const tn = instance.getNode(c.target)! as AppNode;
if (sn.type === "github") {
return c.targetHandle === "repository";
}
@@ -83,33 +94,8 @@
}
return true;
},
- [flow],
+ [instance],
);
- const env = useEnv();
- useEffect(() => {
- const networkNodes: AppNode[] = env.networks.map((n) => ({
- id: n.domain,
- type: "network",
- position: {
- x: 0,
- y: 0,
- },
- isConnectable: true,
- data: {
- domain: n.domain,
- label: n.domain,
- envVars: [],
- ports: [],
- state: "success", // TODO(gio): monitor network health
- },
- }));
- const prevNodes = store.nodes;
- const newNodes = networkNodes.concat(prevNodes.filter((n) => n.type !== "network"));
- // TODO(gio): actually compare
- if (prevNodes.length !== newNodes.length) {
- store.setNodes(newNodes);
- }
- }, [env, store]);
return (
<div style={{ width: "100%", height: "100%" }}>
<ReactFlow
diff --git a/apps/canvas/front/src/components/resources.tsx b/apps/canvas/front/src/components/resources.tsx
index fbb1be7..ddb68ac 100644
--- a/apps/canvas/front/src/components/resources.tsx
+++ b/apps/canvas/front/src/components/resources.tsx
@@ -3,40 +3,38 @@
import { useCallback, useState } from "react";
import { Accordion, AccordionTrigger } from "./ui/accordion";
import { AccordionContent, AccordionItem } from "@radix-ui/react-accordion";
-import { AppNode, AppState, NodeType, useCategories, useStateStore } from "@/lib/state";
+import { AppState, NodeType, useCategories, useStateStore } from "@/lib/state";
import { CategoryItem } from "@/lib/categories";
import { Icon } from "./icon";
function addResource(i: CategoryItem<NodeType>, store: AppState) {
- const node = {
- id: uuidv4(),
- position: {
- x: 0,
- y: 0,
- },
- type: i.type,
- connectable: true,
- data: i.init,
- selected: true,
- };
- const nodes = store.nodes.map((n) => ({
+ const deselected = store.nodes.map((n) => ({
...n,
selected: false,
}));
- nodes.push(node as AppNode);
- store.setNodes(nodes);
+ store.setNodes(deselected);
+ store.addNode({
+ id: uuidv4(),
+ type: i.type,
+ data: i.init,
+ selected: true,
+ connectable: true,
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}
export function Resources() {
const store = useStateStore();
const categories = useCategories();
+
const onResourceAdd = useCallback(
(item: CategoryItem<NodeType>) => {
return () => addResource(item, store);
},
[store],
);
+
const [open, setOpen] = useState<string[]>(categories.map((c) => c.title));
+
return (
<>
<Accordion type="multiple" value={open} onValueChange={(v) => setOpen(v)}>
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index b7fe986..d64f81f 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -2,7 +2,15 @@
import { CreateValidators, Validator } from "./config";
import { GitHubService, GitHubServiceImpl } from "./github";
import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange } from "@xyflow/react";
-import { addEdge, applyEdgeChanges, applyNodeChanges, Connection, EdgeChange, useNodes } from "@xyflow/react";
+import {
+ addEdge,
+ applyEdgeChanges,
+ applyNodeChanges,
+ Connection,
+ EdgeChange,
+ useNodes,
+ XYPosition,
+} from "@xyflow/react";
import type { DeepPartial } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
@@ -355,6 +363,14 @@
type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
+type Viewport = {
+ transformX: number;
+ transformY: number;
+ transformZoom: number;
+ width: number;
+ height: number;
+};
+
export type AppState = {
projectId: string | undefined;
mode: "edit" | "deploy";
@@ -364,11 +380,14 @@
categories: Category[];
messages: Message[];
env: Env;
+ viewport: Viewport;
+ setViewport: (viewport: Viewport) => void;
githubService: GitHubService | null;
setHighlightCategory: (name: string, active: boolean) => void;
onNodesChange: OnNodesChange<AppNode>;
onEdgesChange: OnEdgesChange;
onConnect: OnConnect;
+ addNode: (node: Omit<AppNode, "position">) => void;
setNodes: (nodes: AppNode[]) => void;
setEdges: (edges: Edge[]) => void;
setProject: (projectId: string | undefined) => Promise<void>;
@@ -384,6 +403,11 @@
const messagesSelector = (state: AppState) => state.messages;
const githubServiceSelector = (state: AppState) => state.githubService;
const envSelector = (state: AppState) => state.env;
+const dimensionsSelector = (state: AppState) => state.dimensions;
+
+export function useDimensions(): Dimensions {
+ return useStateStore(dimensionsSelector);
+}
export function useProjectId(): string | undefined {
return useStateStore(projectIdSelector);
@@ -419,6 +443,18 @@
const v: Validator = CreateValidators();
+function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
+ const zoomMultiplier = 1 / transformZoom;
+ const realWidth = width * zoomMultiplier;
+ const realHeight = height * zoomMultiplier;
+ const paddingMultiplier = 0.8;
+ const ret = {
+ x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
+ y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
+ };
+ return ret;
+}
+
export const useStateStore = create<AppState>((set, get): AppState => {
const setN = (nodes: AppNode[]) => {
if (nodes.length == 0) {
@@ -626,7 +662,26 @@
categories: defaultCategories,
messages: v([]),
env: defaultEnv,
+ viewport: {
+ transformX: 0,
+ transformY: 0,
+ transformZoom: 1,
+ width: 800,
+ height: 600,
+ },
githubService: null,
+ setViewport: (viewport) => {
+ const { viewport: vp } = get();
+ if (
+ viewport.transformX !== vp.transformX ||
+ viewport.transformY !== vp.transformY ||
+ viewport.transformZoom !== vp.transformZoom ||
+ viewport.width !== vp.width ||
+ viewport.height !== vp.height
+ ) {
+ set({ viewport });
+ }
+ },
setHighlightCategory: (name, active) => {
set({
categories: get().categories.map((c) => {
@@ -650,6 +705,15 @@
edges: applyEdgeChanges(changes, get().edges),
});
},
+ addNode: (node) => {
+ const { viewport, nodes } = get();
+ setN(
+ nodes.concat({
+ ...node,
+ position: getRandomPosition(viewport),
+ }),
+ );
+ },
setNodes: (nodes) => {
setN(nodes);
},
@@ -705,7 +769,25 @@
console.error("Failed to fetch integrations:", error);
} finally {
if (JSON.stringify(get().env) !== JSON.stringify(env)) {
+ console.log(env);
set({ env });
+ const newNetworks = env.networks.filter(
+ (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
+ );
+ newNetworks.forEach((n) => {
+ get().addNode({
+ id: n.domain,
+ type: "network",
+ connectable: true,
+ data: {
+ domain: n.domain,
+ label: n.domain,
+ envVars: [],
+ ports: [],
+ state: "success", // TODO(gio): monitor network health
+ },
+ });
+ });
if (env.integrations.github) {
set({ githubService: new GitHubServiceImpl(projectId!) });