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!) });