313 lines
9.5 KiB
TypeScript
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;
|
|
}
|