import type { CanvasMediaItem, CanvasOperationPlan, } from "@/types/canvas/agent-protocol"; export type CanvasAgentPlacementNode = { id: string; position?: { x?: number; y?: number }; size?: { width?: number; height?: number }; }; export type ResolvedCanvasAgentMediaPlacement = { item: CanvasMediaItem; position: { x: number; y: number }; size: { width: number; height: number }; sourceNodeIds: string[]; edgeSourceId?: string; }; export type CanvasAgentPlacementViewport = { x: number; y: number; zoom: number; width?: number; height?: number; }; function uniqueStrings(values: Array) { return Array.from( new Set(values.map((value) => String(value || "").trim()).filter(Boolean)) ); } function findNode(nodes: CanvasAgentPlacementNode[], nodeId?: string | null) { if (!nodeId) return null; return nodes.find((node) => node.id === nodeId) || null; } function getNodeBounds(node?: CanvasAgentPlacementNode | null) { if (!node) return null; const width = Math.max(1, Number(node.size?.width || 320) || 320); const height = Math.max(1, Number(node.size?.height || 320) || 320); return { x: Number(node.position?.x || 0) || 0, y: Number(node.position?.y || 0) || 0, width, height, }; } function toFiniteNumber(value: unknown) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } function normalizePlacementViewport( value: unknown ): CanvasAgentPlacementViewport | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const raw = value as Record; const x = toFiniteNumber(raw.x); const y = toFiniteNumber(raw.y); const zoom = toFiniteNumber(raw.zoom); if (x === undefined || y === undefined || zoom === undefined) return undefined; return { x, y, zoom, width: toFiniteNumber(raw.width), height: toFiniteNumber(raw.height), }; } function getPlanViewport(plan: CanvasOperationPlan) { const metadata = plan.metadata && typeof plan.metadata === "object" && !Array.isArray(plan.metadata) ? (plan.metadata as Record) : {}; return normalizePlacementViewport(metadata.canvasAgentViewport); } function getEffectiveViewport(params: { plan: CanvasOperationPlan; viewport?: CanvasAgentPlacementViewport; }) { return getPlanViewport(params.plan) || params.viewport; } function getViewportCenter(viewport?: CanvasAgentPlacementViewport) { const zoom = Math.max(0.1, Number(viewport?.zoom || 1) || 1); const width = toFiniteNumber(viewport?.width); const height = toFiniteNumber(viewport?.height); return { x: ((width && width > 0 ? width / 2 : 0) - Number(viewport?.x || 0)) / zoom, y: ((height && height > 0 ? height / 2 : 0) - Number(viewport?.y || 0)) / zoom, }; } function getViewportCenteredPosition(params: { index: number; itemCount: number; groupIndex: number; size: { width: number; height: number }; strategy: CanvasOperationPlan["placementStrategy"]; viewport?: CanvasAgentPlacementViewport; }) { const stepGap = 24; const center = getViewportCenter(params.viewport); const placementIndex = params.strategy === "beside_each_source" ? params.groupIndex : params.index; if (params.strategy === "grid") { const cols = Math.max(1, Math.ceil(Math.sqrt(params.itemCount))); const rows = Math.max(1, Math.ceil(params.itemCount / cols)); const col = placementIndex % cols; const row = Math.floor(placementIndex / cols); const totalWidth = cols * params.size.width + (cols - 1) * stepGap; const totalHeight = rows * params.size.height + (rows - 1) * stepGap; return { x: center.x - totalWidth / 2 + col * (params.size.width + stepGap), y: center.y - totalHeight / 2 + row * (params.size.height + stepGap), }; } const totalWidth = params.itemCount * params.size.width + (params.itemCount - 1) * stepGap; return { x: center.x - totalWidth / 2 + placementIndex * (params.size.width + stepGap), y: center.y - params.size.height / 2, }; } function getItemSourceNodeIds(item: CanvasMediaItem, plan: CanvasOperationPlan) { const itemSourceNodeIds = Array.isArray(item.sourceNodeIds) ? item.sourceNodeIds : []; const primarySourceIds = itemSourceNodeIds.length > 0 ? itemSourceNodeIds : plan.sourceNodeIds || []; return uniqueStrings([ ...primarySourceIds, plan.operation === "fill_slot" ? plan.targetSlotId : plan.anchorNodeId, ]); } function getAnchorNode(params: { plan: CanvasOperationPlan; item: CanvasMediaItem; nodes: CanvasAgentPlacementNode[]; targetSlot?: CanvasAgentPlacementNode | null; sourceNodeIds: string[]; }) { if (params.targetSlot && params.plan.operation === "fill_slot") { return params.targetSlot; } if (params.plan.placementStrategy === "beside_each_source") { const itemSourceNodeId = Array.isArray(params.item.sourceNodeIds) ? params.item.sourceNodeIds.find(Boolean) : null; const itemSourceNode = findNode(params.nodes, itemSourceNodeId); if (itemSourceNode) return itemSourceNode; } return ( findNode(params.nodes, params.plan.anchorNodeId) || params.sourceNodeIds .map((nodeId) => findNode(params.nodes, nodeId)) .find(Boolean) || null ); } function resolveSize(params: { item: CanvasMediaItem; anchorNode?: CanvasAgentPlacementNode | null; targetSlot?: CanvasAgentPlacementNode | null; isSlotFill: boolean; }) { const slotBounds = getNodeBounds(params.targetSlot); if (params.isSlotFill && slotBounds) { return { width: slotBounds.width, height: slotBounds.height, }; } const anchorBounds = getNodeBounds(params.anchorNode); return { width: Math.max(1, Number(params.item.width || anchorBounds?.width || 320)), height: Math.max(1, Number(params.item.height || anchorBounds?.height || 320)), }; } function resolvePosition(params: { plan: CanvasOperationPlan; index: number; itemCount: number; groupIndex: number; size: { width: number; height: number }; anchorNode?: CanvasAgentPlacementNode | null; targetSlot?: CanvasAgentPlacementNode | null; viewport?: CanvasAgentPlacementViewport; }) { const gap = 40; const stepGap = 24; const targetSlotBounds = getNodeBounds(params.targetSlot); if (params.plan.operation === "fill_slot" && targetSlotBounds) { return { x: targetSlotBounds.x, y: targetSlotBounds.y }; } const anchorBounds = getNodeBounds(params.anchorNode); const placement = params.plan.placement || "right"; const strategy = params.plan.placementStrategy || "single"; const placementIndex = strategy === "beside_each_source" ? params.groupIndex : params.index; if (strategy === "overlay" && anchorBounds) { return { x: anchorBounds.x, y: anchorBounds.y }; } if (placement === "viewport-center") { return getViewportCenteredPosition({ index: params.index, itemCount: params.itemCount, groupIndex: params.groupIndex, size: params.size, strategy, viewport: params.viewport, }); } if (strategy === "grid") { const cols = Math.max(1, Math.ceil(Math.sqrt(params.itemCount))); const col = placementIndex % cols; const row = Math.floor(placementIndex / cols); if (anchorBounds) { if (placement === "left") { return { x: anchorBounds.x - gap - (col + 1) * params.size.width - col * stepGap, y: anchorBounds.y + row * (params.size.height + stepGap), }; } if (placement === "above") { return { x: anchorBounds.x + col * (params.size.width + stepGap), y: anchorBounds.y - gap - (row + 1) * params.size.height - row * stepGap, }; } if (placement === "below") { return { x: anchorBounds.x + col * (params.size.width + stepGap), y: anchorBounds.y + anchorBounds.height + gap + row * (params.size.height + stepGap), }; } return { x: anchorBounds.x + anchorBounds.width + gap + col * (params.size.width + stepGap), y: anchorBounds.y + row * (params.size.height + stepGap), }; } const center = getViewportCenter(params.viewport); return { x: center.x + col * (params.size.width + stepGap), y: center.y + row * (params.size.height + stepGap), }; } if (anchorBounds) { if (placement === "left") { return { x: anchorBounds.x - gap - params.size.width - placementIndex * (params.size.width + stepGap), y: anchorBounds.y, }; } if (placement === "above") { return { x: anchorBounds.x + placementIndex * (params.size.width + stepGap), y: anchorBounds.y - gap - params.size.height, }; } if (placement === "below") { return { x: anchorBounds.x + placementIndex * (params.size.width + stepGap), y: anchorBounds.y + anchorBounds.height + gap, }; } return { x: anchorBounds.x + anchorBounds.width + gap + placementIndex * (params.size.width + stepGap), y: anchorBounds.y, }; } const center = getViewportCenter(params.viewport); return { x: center.x + placementIndex * (params.size.width + stepGap), y: center.y, }; } export function resolveCanvasAgentMediaPlacements(params: { plan: CanvasOperationPlan; mediaItems: CanvasMediaItem[]; nodes: CanvasAgentPlacementNode[]; targetSlot?: CanvasAgentPlacementNode | null; viewport?: CanvasAgentPlacementViewport; }): ResolvedCanvasAgentMediaPlacement[] { const groupCounts = new Map(); const viewport = getEffectiveViewport({ plan: params.plan, viewport: params.viewport, }); return params.mediaItems.map((item, index) => { const sourceNodeIds = getItemSourceNodeIds(item, params.plan); const anchorNode = getAnchorNode({ plan: params.plan, item, nodes: params.nodes, targetSlot: params.targetSlot, sourceNodeIds, }); const anchorKey = anchorNode?.id || "__canvas"; const groupIndex = groupCounts.get(anchorKey) || 0; groupCounts.set(anchorKey, groupIndex + 1); const isSlotFill = Boolean(params.targetSlot && params.plan.operation === "fill_slot"); const size = resolveSize({ item, anchorNode, targetSlot: params.targetSlot, isSlotFill, }); const position = resolvePosition({ plan: params.plan, index, itemCount: params.mediaItems.length, groupIndex, size, anchorNode, targetSlot: params.targetSlot, viewport, }); return { item, position, size, sourceNodeIds, edgeSourceId: params.targetSlot?.id || anchorNode?.id || sourceNodeIds[0] || params.plan.anchorNodeId, }; }); }