360 lines
11 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
}
|