import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol"; import type { NodeType } from "@/types/canvas"; export type CanvasAgentViewNodeLike = { id: string; type?: string; position?: { x?: number; y?: number }; size?: { width?: number; height?: number }; data?: unknown; createdAt?: string | number | Date; updatedAt?: string | number | Date; }; export type CanvasAgentViewportLike = { x: number; y: number; zoom: number; }; export type CanvasAgentStageLike = { width: number; height: number; }; export type ResolvedCanvasViewOperation = { viewport: CanvasAgentViewportLike; targetNodeIds: string[]; }; const VIEWPORT_ANCHOR_X_RATIO = 0.52; const VIEWPORT_ANCHOR_Y_RATIO = 0.46; const FIT_ALL_VISIBLE_NODE_TYPES = new Set([ "message", "image", "video", "text", "ui-screen", "workflow", "scene-fusion", "model3d", "world3d", "agent-slot", ] satisfies NodeType[]); function toFiniteNumber(value: unknown, fallback = 0) { const numericValue = Number(value); return Number.isFinite(numericValue) ? numericValue : fallback; } function getNodeTimestamp(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) return value; if (value instanceof Date) return value.getTime(); if (typeof value !== "string" || !value.trim()) return 0; const timestamp = Date.parse(value); return Number.isFinite(timestamp) ? timestamp : 0; } function getCanvasAgentResultScore(node: CanvasAgentViewNodeLike) { const data = node.data && typeof node.data === "object" && !Array.isArray(node.data) ? (node.data as Record) : {}; const metadata = data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata) ? (data.metadata as Record) : {}; const generatedByAgent = metadata.generatedByAgent === true || metadata.latestGeneratedHighlight === true || typeof metadata.canvasAgentOperationId === "string"; if (!generatedByAgent) return 0; return Math.max( getNodeTimestamp(metadata.recentlyGeneratedAt), getNodeTimestamp(metadata.agentGeneratedAt), getNodeTimestamp(metadata.agentReplacedAt), getNodeTimestamp(node.updatedAt), getNodeTimestamp(node.createdAt), 1 ); } export function resolveCanvasViewOperationTargetNodeIds(params: { plan: Pick; nodes: CanvasAgentViewNodeLike[]; selectedNodeIds?: string[]; maxLatestResults?: number; }) { const explicitNodeIds = Array.isArray(params.plan.nodeIds) ? params.plan.nodeIds.map((id) => String(id || "").trim()).filter(Boolean) : []; if (explicitNodeIds.length > 0) return Array.from(new Set(explicitNodeIds)); const anchorNodeId = String(params.plan.anchorNodeId || "").trim(); if (anchorNodeId) return [anchorNodeId]; if (params.plan.operation === "focus_selection") { return Array.isArray(params.selectedNodeIds) ? params.selectedNodeIds.map((id) => String(id || "").trim()).filter(Boolean) : []; } if (params.plan.operation === "focus_latest_result") { const maxLatestResults = Math.max(1, Math.min(8, params.maxLatestResults || 4)); return params.nodes .map((node) => ({ id: node.id, score: getCanvasAgentResultScore(node), })) .filter((item) => item.id && item.score > 0) .sort((a, b) => b.score - a.score) .slice(0, maxLatestResults) .map((item) => item.id); } return []; } export function computeCanvasAgentPanViewport(params: { currentViewport: CanvasAgentViewportLike; center: { x?: number; y?: number }; stage: CanvasAgentStageLike; zoom?: number; }) { const currentZoom = Math.max(0.1, toFiniteNumber(params.currentViewport.zoom, 1)); const zoom = Math.max( 0.1, Math.min(4, toFiniteNumber(params.zoom, currentZoom)) ); const centerX = toFiniteNumber(params.center.x, 0); const centerY = toFiniteNumber(params.center.y, 0); const stageWidth = Math.max(1, toFiniteNumber(params.stage.width, 1200)); const stageHeight = Math.max(1, toFiniteNumber(params.stage.height, 800)); return { x: stageWidth * VIEWPORT_ANCHOR_X_RATIO - centerX * zoom, y: stageHeight * VIEWPORT_ANCHOR_Y_RATIO - centerY * zoom, zoom, }; } function getNodeBounds(node?: CanvasAgentViewNodeLike | null) { if (!node) return null; const width = Math.max(1, toFiniteNumber(node.size?.width, 300)); const height = Math.max(1, toFiniteNumber(node.size?.height, 300)); return { x: toFiniteNumber(node.position?.x, 0), y: toFiniteNumber(node.position?.y, 0), width, height, }; } function computeCanvasAgentFocusNodeViewport(params: { node: CanvasAgentViewNodeLike; stage: CanvasAgentStageLike; }) { const bounds = getNodeBounds(params.node); if (!bounds) return null; const stageWidth = Math.max(1, toFiniteNumber(params.stage.width, 1200)); const stageHeight = Math.max(1, toFiniteNumber(params.stage.height, 800)); const availableWidth = Math.max(360, stageWidth * 0.54); const availableHeight = Math.max(320, stageHeight * 0.52); const fitZoom = Math.min( 1.16, availableWidth / bounds.width, availableHeight / bounds.height ); const zoom = Math.max(0.48, Math.min(fitZoom, 4)); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return computeCanvasAgentPanViewport({ currentViewport: { x: 0, y: 0, zoom: 1 }, center: { x: centerX, y: centerY }, stage: params.stage, zoom, }); } function computeCanvasAgentFocusNodesViewport(params: { nodes: CanvasAgentViewNodeLike[]; stage: CanvasAgentStageLike; padding?: number; }) { if (params.nodes.length === 0) return null; if (params.nodes.length === 1) { return computeCanvasAgentFocusNodeViewport({ node: params.nodes[0], stage: params.stage, }); } const bounds = params.nodes.map(getNodeBounds).filter(Boolean) as Array<{ x: number; y: number; width: number; height: number; }>; if (bounds.length === 0) return null; const padding = Math.max(0, toFiniteNumber(params.padding, 120)); const minX = Math.min(...bounds.map((bound) => bound.x)); const minY = Math.min(...bounds.map((bound) => bound.y)); const maxX = Math.max(...bounds.map((bound) => bound.x + bound.width)); const maxY = Math.max(...bounds.map((bound) => bound.y + bound.height)); const contentWidth = Math.max(1, maxX - minX + padding * 2); const contentHeight = Math.max(1, maxY - minY + padding * 2); const stageWidth = Math.max(1, toFiniteNumber(params.stage.width, 1200)); const stageHeight = Math.max(1, toFiniteNumber(params.stage.height, 800)); const availableWidth = Math.max(360, stageWidth * 0.54); const availableHeight = Math.max(320, stageHeight * 0.58); const zoom = Math.max( 0.08, Math.min(1.4, availableWidth / contentWidth, availableHeight / contentHeight, 4) ); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; return computeCanvasAgentPanViewport({ currentViewport: { x: 0, y: 0, zoom: 1 }, center: { x: centerX, y: centerY }, stage: params.stage, zoom, }); } function getFitAllViewNodes(nodes: CanvasAgentViewNodeLike[]) { return nodes.filter((node) => FIT_ALL_VISIBLE_NODE_TYPES.has(String(node.type) as NodeType) ); } export function resolveCanvasViewOperation(params: { plan: CanvasViewOperationPlan; nodes: CanvasAgentViewNodeLike[]; selectedNodeIds?: string[]; currentViewport: CanvasAgentViewportLike; stage: CanvasAgentStageLike; }): ResolvedCanvasViewOperation | null { const operation = params.plan.operation; const stage = { width: Math.max(1, toFiniteNumber(params.stage.width, 1200)), height: Math.max(1, toFiniteNumber(params.stage.height, 800)), }; const currentViewport = { x: toFiniteNumber(params.currentViewport.x, 0), y: toFiniteNumber(params.currentViewport.y, 0), zoom: Math.max(0.1, toFiniteNumber(params.currentViewport.zoom, 1)), }; if (operation === "zoom_in" || operation === "zoom_out") { const factor = operation === "zoom_in" ? 1.22 : 0.82; const nextZoom = Math.max(0.1, Math.min(4, currentViewport.zoom * factor)); const anchorX = stage.width * VIEWPORT_ANCHOR_X_RATIO; const anchorY = stage.height * VIEWPORT_ANCHOR_Y_RATIO; const scaleChange = nextZoom / currentViewport.zoom; return { viewport: { x: anchorX - (anchorX - currentViewport.x) * scaleChange, y: anchorY - (anchorY - currentViewport.y) * scaleChange, zoom: nextZoom, }, targetNodeIds: [], }; } if (operation === "zoom_to" && Number.isFinite(Number(params.plan.zoom))) { return { viewport: { ...currentViewport, zoom: Math.max(0.1, Math.min(4, Number(params.plan.zoom))), }, targetNodeIds: [], }; } if (operation === "pan_to" && params.plan.center) { return { viewport: computeCanvasAgentPanViewport({ currentViewport, center: params.plan.center, stage, zoom: params.plan.zoom, }), targetNodeIds: [], }; } const targetNodeIds = resolveCanvasViewOperationTargetNodeIds({ plan: params.plan, nodes: params.nodes, selectedNodeIds: params.selectedNodeIds, }); const targetNodes = operation === "fit_all" ? getFitAllViewNodes(params.nodes) : params.nodes.filter((node) => targetNodeIds.includes(node.id)); const viewport = computeCanvasAgentFocusNodesViewport({ nodes: targetNodes, stage, padding: params.plan.padding, }); return viewport ? { viewport, targetNodeIds, } : null; }