ai-chatbot/lib/canvas/agent-view-operations.ts

313 lines
9.5 KiB
TypeScript

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<string, unknown>)
: {};
const metadata =
data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata)
? (data.metadata as Record<string, unknown>)
: {};
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<CanvasViewOperationPlan, "operation" | "nodeIds" | "anchorNodeId">;
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;
}