Canvas: Auto-assign position to nodes if missing

Change-Id: Ica80878e0cb280c9a44f58637c11b53d78e07892
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index e309926..c2f8e05 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -577,6 +577,14 @@
 	targetHandle: z.string().optional(),
 });
 
+export const ViewportTransformSchema = z.object({
+	transformX: z.number(),
+	transformY: z.number(),
+	transformZoom: z.number(),
+	width: z.number(),
+	height: z.number(),
+});
+
 export const GraphSchema = z.object({
 	nodes: z.array(NodeSchema),
 	edges: z.array(EdgeSchema),
@@ -587,6 +595,7 @@
 			zoom: z.number(),
 		})
 		.optional(),
+	viewportTransform: ViewportTransformSchema.optional(),
 });
 
 export const GraphOrConfigSchema = z.discriminatedUnion("type", [
@@ -601,4 +610,5 @@
 ]);
 
 export type Edge = z.infer<typeof EdgeSchema>;
+export type ViewportTransform = z.infer<typeof ViewportTransformSchema>;
 export type Graph = z.infer<typeof GraphSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index 4a6121e..c10ce31 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -51,8 +51,10 @@
 	Access,
 	AgentAccess,
 	Graph,
+	GraphSchema,
 	Edge,
 	GraphOrConfigSchema,
+	ViewportTransform,
 } from "./graph.js";
 
 export { generateDodoConfig, configToGraph } from "./config.js";
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index b0237a9..fcfe011 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -14,7 +14,17 @@
 import type { DeepPartial } from "react-hook-form";
 import { v4 as uuidv4 } from "uuid";
 import { create } from "zustand";
-import { AppNode, Env, NodeType, VolumeNode, GatewayTCPData, envSchema, AgentAccess } from "config";
+import {
+	AppNode,
+	Env,
+	NodeType,
+	VolumeNode,
+	GatewayTCPData,
+	envSchema,
+	AgentAccess,
+	ViewportTransform,
+	GraphSchema,
+} from "config";
 
 export function nodeLabel(n: AppNode): string {
 	try {
@@ -183,14 +193,6 @@
 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;
-};
-
 let refreshEnvIntervalId: number | null = null;
 
 export type AppState = {
@@ -204,8 +206,8 @@
 	categories: Category[];
 	messages: Message[];
 	env: Env;
-	viewport: Viewport;
-	setViewport: (viewport: Viewport) => void;
+	viewport: ViewportTransform;
+	setViewport: (viewport: ViewportTransform) => void;
 	githubService: GitHubService | null;
 	githubRepositories: GitHubRepository[];
 	githubRepositoriesLoading: boolean;
@@ -323,7 +325,7 @@
 
 const v: Validator = CreateValidators();
 
-function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
+function getRandomPosition({ width, height, transformX, transformY, transformZoom }: ViewportTransform): XYPosition {
 	const zoomMultiplier = 1 / transformZoom;
 	const realWidth = width * zoomMultiplier;
 	const realHeight = height * zoomMultiplier;
@@ -629,11 +631,41 @@
 		set({ stateEventSource: eventSource });
 
 		eventSource.onmessage = (event) => {
-			const inst = JSON.parse(event.data);
-			setN(inst.nodes);
+			const { mode, viewport } = get();
+			const inst = GraphSchema.parse(JSON.parse(event.data));
+			let positionChanged = false;
+			let nodes = inst.nodes;
+			if (mode === "edit") {
+				nodes = inst.nodes.map((n) => {
+					if (n.position.x === 0 && n.position.y === 0) {
+						positionChanged = true;
+						return {
+							...n,
+							position: getRandomPosition(viewport),
+						};
+					} else {
+						return n;
+					}
+				});
+			}
+			setN(nodes);
 			set({ edges: inst.edges });
 			injectNetworkNodes();
-			// TODO(gio): set viewport
+			if (positionChanged) {
+				fetch(`/api/project/${projectId}/saved`, {
+					method: "POST",
+					headers: {
+						"Content-Type": "application/json",
+					},
+					body: JSON.stringify({
+						type: "graph",
+						graph: {
+							...inst,
+							nodes,
+						},
+					}),
+				});
+			}
 		};
 
 		eventSource.onerror = (err) => {