ai-chatbot/lib/canvas/agent-placement.ts

360 lines
11 KiB
TypeScript

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<string | undefined | null>) {
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<string, unknown>;
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<string, unknown>)
: {};
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<string, number>();
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,
};
});
}