Spaces:
Sleeping
Sleeping
| import cytoscape, { Core, NodeSingular, EdgeSingular } from "cytoscape"; | |
| import cola from "cytoscape-cola"; | |
| import { TemporalNode, TemporalLink } from "@/types/temporal"; | |
| import { | |
| UniversalGraphData, | |
| UniversalNode, | |
| UniversalLink, | |
| GraphVisualizationConfig, | |
| GraphSelectionCallbacks, | |
| } from "@/types/graph-visualization"; | |
| import { | |
| getNodeColor, | |
| getNodeSize, | |
| getRelationColor, | |
| getRelationLineStyle, | |
| getRelationWidth, | |
| getRelationTextColor, | |
| } from "./graph-data-utils"; | |
| import { getNodeIcon } from "./node-icons"; | |
| // Register the cola extension | |
| cytoscape.use(cola); | |
| // Legacy interface for backward compatibility | |
| export interface ElementSelectionCallbacks { | |
| onNodeSelect?: (node: TemporalNode) => void; | |
| onLinkSelect?: (link: TemporalLink) => void; | |
| onClearSelection?: () => void; | |
| } | |
| interface ExecutionOrder { | |
| taskOrder: Map<string, number>; | |
| toolOrder: Map<string, number>; | |
| agentOrder: Map<string, number>; | |
| outputOrder: Map<string, number>; | |
| } | |
| interface EntityLayout { | |
| [nodeId: string]: { | |
| x: number; | |
| y: number; | |
| }; | |
| } | |
| export class CytoscapeGraphCore { | |
| private cy: Core | null = null; | |
| private container: HTMLElement; | |
| private config: GraphVisualizationConfig; | |
| private currentData: UniversalGraphData; | |
| private callbacks: GraphSelectionCallbacks; | |
| private selectedElement: { type: "node" | "link"; id: string } | null = null; | |
| constructor( | |
| containerElement: HTMLElement, | |
| config: GraphVisualizationConfig, | |
| callbacks: GraphSelectionCallbacks = {} | |
| ) { | |
| this.container = containerElement; | |
| this.config = config; | |
| this.currentData = { nodes: [], links: [] }; | |
| this.callbacks = callbacks; | |
| this.init(); | |
| } | |
| private getExecutionOrder( | |
| nodes: UniversalNode[], | |
| links: UniversalLink[] | |
| ): ExecutionOrder { | |
| const taskOrder = new Map<string, number>(); | |
| const toolOrder = new Map<string, number>(); | |
| const agentOrder = new Map<string, number>(); | |
| const outputOrder = new Map<string, number>(); | |
| const nextRelations = links.filter((l) => l.type === "NEXT"); | |
| const performsRelations = links.filter( | |
| (l) => l.type === "PERFORMS" || l.type === "ASSIGNED_TO" | |
| ); | |
| const usesRelations = links.filter((l) => l.type === "USES"); | |
| const producesRelations = links.filter((l) => l.type === "PRODUCES"); | |
| // Build task sequence | |
| let taskIndex = 0; | |
| const processedTasks = new Set<string>(); | |
| const allTasks = nodes.filter((n) => n.type === "Task"); | |
| // Find tasks involved in NEXT relations | |
| const tasksInNextRelations = new Set<string>(); | |
| nextRelations.forEach((rel) => { | |
| const sourceId = | |
| typeof rel.source === "string" ? rel.source : rel.source.id; | |
| const targetId = | |
| typeof rel.target === "string" ? rel.target : rel.target.id; | |
| tasksInNextRelations.add(sourceId); | |
| tasksInNextRelations.add(targetId); | |
| }); | |
| // 1. First, add isolated tasks (not connected to other tasks) | |
| const isolatedTasks = allTasks.filter( | |
| (t) => !tasksInNextRelations.has(t.id) | |
| ); | |
| isolatedTasks.forEach((task) => { | |
| taskOrder.set(task.id, taskIndex++); | |
| processedTasks.add(task.id); | |
| }); | |
| // 2. Then, process tasks with NEXT relations in order | |
| const tasksWithPredecessors = new Set( | |
| nextRelations.map((r) => | |
| typeof r.target === "string" ? r.target : r.target.id | |
| ) | |
| ); | |
| const startTasks = allTasks.filter( | |
| (t) => !tasksWithPredecessors.has(t.id) && tasksInNextRelations.has(t.id) | |
| ); | |
| const processTask = (taskId: string) => { | |
| if (processedTasks.has(taskId)) return; | |
| taskOrder.set(taskId, taskIndex++); | |
| processedTasks.add(taskId); | |
| const nextTasks = nextRelations.filter((r) => { | |
| const sourceId = typeof r.source === "string" ? r.source : r.source.id; | |
| return sourceId === taskId; | |
| }); | |
| nextTasks.forEach((r) => { | |
| const targetId = typeof r.target === "string" ? r.target : r.target.id; | |
| processTask(targetId); | |
| }); | |
| }; | |
| startTasks.forEach((task) => processTask(task.id)); | |
| // 3. Finally, add any remaining tasks | |
| allTasks.forEach((task) => { | |
| if (!processedTasks.has(task.id)) { | |
| taskOrder.set(task.id, taskIndex++); | |
| } | |
| }); | |
| // Order agents by earliest task they perform | |
| let agentIndex = 0; | |
| const agentEarliestTask = new Map<string, number>(); | |
| performsRelations.forEach((rel) => { | |
| const sourceId = | |
| typeof rel.source === "string" ? rel.source : rel.source.id; | |
| const targetId = | |
| typeof rel.target === "string" ? rel.target : rel.target.id; | |
| const taskIdx = taskOrder.get(targetId) ?? 999; | |
| if ( | |
| !agentEarliestTask.has(sourceId) || | |
| agentEarliestTask.get(sourceId)! > taskIdx | |
| ) { | |
| agentEarliestTask.set(sourceId, taskIdx); | |
| } | |
| }); | |
| const sortedAgents = Array.from(agentEarliestTask.entries()).sort( | |
| (a, b) => a[1] - b[1] | |
| ); | |
| sortedAgents.forEach(([agentId]) => { | |
| agentOrder.set(agentId, agentIndex++); | |
| }); | |
| // Add unconnected agents at the end | |
| nodes | |
| .filter((n) => n.type === "Agent") | |
| .forEach((agent) => { | |
| if (!agentOrder.has(agent.id)) { | |
| agentOrder.set(agent.id, agentIndex++); | |
| } | |
| }); | |
| // Order tools by usage order | |
| let toolIndex = 0; | |
| const toolFirstUse = new Map<string, number>(); | |
| usesRelations.forEach((rel) => { | |
| const sourceId = | |
| typeof rel.source === "string" ? rel.source : rel.source.id; | |
| const targetId = | |
| typeof rel.target === "string" ? rel.target : rel.target.id; | |
| const agentIdx = agentOrder.get(sourceId) || 0; | |
| if ( | |
| !toolFirstUse.has(targetId) || | |
| toolFirstUse.get(targetId)! > agentIdx | |
| ) { | |
| toolFirstUse.set(targetId, agentIdx); | |
| } | |
| }); | |
| const sortedTools = Array.from(toolFirstUse.entries()).sort( | |
| (a, b) => a[1] - b[1] | |
| ); | |
| sortedTools.forEach(([toolId]) => { | |
| toolOrder.set(toolId, toolIndex++); | |
| }); | |
| // Order outputs by task creation order | |
| let outputIndex = 0; | |
| const outputTaskOrder = new Map<string, number>(); | |
| producesRelations.forEach((rel) => { | |
| const sourceId = | |
| typeof rel.source === "string" ? rel.source : rel.source.id; | |
| const targetId = | |
| typeof rel.target === "string" ? rel.target : rel.target.id; | |
| const taskIdx = taskOrder.get(sourceId) ?? 999; | |
| if ( | |
| !outputTaskOrder.has(targetId) || | |
| outputTaskOrder.get(targetId)! > taskIdx | |
| ) { | |
| outputTaskOrder.set(targetId, taskIdx); | |
| } | |
| }); | |
| const sortedOutputs = Array.from(outputTaskOrder.entries()).sort( | |
| (a, b) => a[1] - b[1] | |
| ); | |
| sortedOutputs.forEach(([outputId]) => { | |
| outputOrder.set(outputId, outputIndex++); | |
| }); | |
| // Add unconnected outputs at the end | |
| nodes | |
| .filter((n) => n.type === "Output") | |
| .forEach((output) => { | |
| if (!outputOrder.has(output.id)) { | |
| outputOrder.set(output.id, outputIndex++); | |
| } | |
| }); | |
| return { taskOrder, toolOrder, agentOrder, outputOrder }; | |
| } | |
| private calculateStructuredLayout( | |
| nodes: UniversalNode[], | |
| links: UniversalLink[] | |
| ): EntityLayout { | |
| const layout: EntityLayout = {}; | |
| // Dynamic Y spacing based on node count - fewer nodes = tighter spacing | |
| const totalNodes = nodes.length; | |
| const baseYSpacing = Math.max(120, Math.min(150, 60 + totalNodes * 8)); | |
| const nodeSpacing = baseYSpacing; | |
| // STEP 1: Identify isolated nodes FIRST (before positioning) | |
| const connectedNodeIds = new Set<string>(); | |
| links.forEach((link) => { | |
| const sourceId = | |
| typeof link.source === "string" ? link.source : link.source.id; | |
| const targetId = | |
| typeof link.target === "string" ? link.target : link.target.id; | |
| connectedNodeIds.add(sourceId); | |
| connectedNodeIds.add(targetId); | |
| }); | |
| const isolatedNodes = nodes.filter( | |
| (node) => !connectedNodeIds.has(node.id) | |
| ); | |
| const connectedNodes = nodes.filter((node) => | |
| connectedNodeIds.has(node.id) | |
| ); | |
| // Get execution order for CONNECTED nodes only | |
| const { taskOrder, toolOrder, agentOrder, outputOrder } = | |
| this.getExecutionOrder(connectedNodes, links); | |
| // Separate CONNECTED nodes by type for main workflow | |
| const inputs = connectedNodes.filter((n) => n.type === "Input"); | |
| const agents = connectedNodes.filter((n) => n.type === "Agent"); | |
| const tools = connectedNodes.filter((n) => n.type === "Tool"); | |
| const tasks = connectedNodes.filter((n) => n.type === "Task"); | |
| const outputs = connectedNodes.filter((n) => n.type === "Output"); | |
| const humans = connectedNodes.filter((n) => n.type === "Human"); | |
| // STEP 2: Position isolated nodes in TOP zone | |
| const isolatedZoneX = this.config.width * 0.5; | |
| const isolatedSpacing = Math.floor(nodeSpacing * 0.67); | |
| isolatedNodes.forEach((node, i) => { | |
| const startY = this.config.height * 0.05; | |
| layout[node.id] = { | |
| x: isolatedZoneX, | |
| y: startY + i * isolatedSpacing, | |
| }; | |
| }); | |
| // STEP 3: Position connected nodes in main workflow area | |
| const isolatedBottomY = | |
| isolatedNodes.length > 0 | |
| ? this.config.height * 0.05 + | |
| (isolatedNodes.length - 1) * isolatedSpacing | |
| : this.config.height * 0.05; | |
| // Main workflow should start with moderate gap from isolated bottom | |
| const mainWorkflowTopGap = Math.min(175, nodeSpacing * 1.5); | |
| const mainWorkflowStartY = isolatedBottomY + mainWorkflowTopGap; | |
| const centerY = mainWorkflowStartY + this.config.height * 0.2; | |
| // Keep inputs within the visible canvas with a small left margin | |
| const inputX = this.config.width * 0.04; | |
| const agentX = this.config.width * 0.14; | |
| const toolX = this.config.width * 0.3; | |
| const taskX = this.config.width * 0.5; | |
| const outputX = this.config.width * 0.7; | |
| const humanX = this.config.width * 0.86; | |
| // Position Tasks based on execution order | |
| const sortedTasks = [...tasks].sort((a, b) => { | |
| const orderA = taskOrder.get(a.id) ?? 999; | |
| const orderB = taskOrder.get(b.id) ?? 999; | |
| return orderA - orderB; | |
| }); | |
| // Find SUBTASK_OF relations | |
| const subtaskRelations = links.filter((l) => l.type === "SUBTASK_OF"); | |
| const childrenByParent = new Map<string, string[]>(); | |
| subtaskRelations.forEach((rel) => { | |
| const parentId = | |
| typeof rel.target === "string" ? rel.target : rel.target.id; | |
| const childId = | |
| typeof rel.source === "string" ? rel.source : rel.source.id; | |
| if (!childrenByParent.has(parentId)) { | |
| childrenByParent.set(parentId, []); | |
| } | |
| childrenByParent.get(parentId)!.push(childId); | |
| }); | |
| sortedTasks.forEach((node, i) => { | |
| const totalTasks = sortedTasks.length; | |
| const startY = centerY - ((totalTasks - 1) * nodeSpacing) / 2; | |
| let xPosition = taskX; | |
| // Apply X offset for SUBTASK_OF children | |
| subtaskRelations.forEach((rel) => { | |
| const childId = | |
| typeof rel.source === "string" ? rel.source : rel.source.id; | |
| if (childId === node.id) { | |
| const parentId = | |
| typeof rel.target === "string" ? rel.target : rel.target.id; | |
| const siblings = childrenByParent.get(parentId) || []; | |
| if (siblings.length > 1) { | |
| xPosition = taskX + (Math.random() * 100 - 50); | |
| } | |
| } | |
| }); | |
| layout[node.id] = { | |
| x: xPosition, | |
| y: startY + i * nodeSpacing, | |
| }; | |
| }); | |
| // Position Agents FIRST (before tools to get layout info) | |
| const sortedAgents = [...agents].sort((a, b) => { | |
| const orderA = agentOrder.get(a.id) ?? 999; | |
| const orderB = agentOrder.get(b.id) ?? 999; | |
| return orderA - orderB; | |
| }); | |
| sortedAgents.forEach((node, i) => { | |
| const totalAgents = sortedAgents.length; | |
| const startY = centerY - ((totalAgents - 1) * nodeSpacing) / 2; | |
| layout[node.id] = { | |
| x: agentX, | |
| y: startY + i * nodeSpacing, | |
| }; | |
| }); | |
| // Position Tools (between Agent and Task) - NOW agents are positioned | |
| const connectedTools: { tool: UniversalNode; avgY: number }[] = []; | |
| const unconnectedTools: { tool: UniversalNode; avgY: number }[] = []; | |
| tools.forEach((tool) => { | |
| const hasConnection = links.some( | |
| (l) => | |
| (l.type === "USES" && | |
| (typeof l.target === "string" ? l.target : l.target.id) === | |
| tool.id) || | |
| (l.type === "REQUIRED_BY" && | |
| (typeof l.source === "string" ? l.source : l.source.id) === tool.id) | |
| ); | |
| if (hasConnection) { | |
| // For connected tools, calculate avgY based on connected agents/tasks | |
| let totalY = 0; | |
| let count = 0; | |
| const usesRelations = links.filter( | |
| (l) => | |
| l.type === "USES" && | |
| (typeof l.target === "string" ? l.target : l.target.id) === tool.id | |
| ); | |
| const requiredByRelations = links.filter( | |
| (l) => | |
| l.type === "REQUIRED_BY" && | |
| (typeof l.source === "string" ? l.source : l.source.id) === tool.id | |
| ); | |
| [...usesRelations, ...requiredByRelations].forEach((rel) => { | |
| const relatedNodeId = | |
| rel.type === "USES" | |
| ? typeof rel.source === "string" | |
| ? rel.source | |
| : rel.source.id | |
| : typeof rel.target === "string" | |
| ? rel.target | |
| : rel.target.id; | |
| const relatedNode = [...agents, ...tasks].find( | |
| (n) => n.id === relatedNodeId | |
| ); | |
| const relatedNodeLayout = relatedNode | |
| ? layout[relatedNode.id] | |
| : undefined; | |
| if (relatedNode && relatedNodeLayout) { | |
| totalY += relatedNodeLayout.y; | |
| count++; | |
| } | |
| }); | |
| const avgY = count > 0 ? totalY / count : centerY; | |
| connectedTools.push({ tool, avgY }); | |
| } else { | |
| // Unconnected tools get dummy avgY for sorting | |
| unconnectedTools.push({ tool, avgY: centerY }); | |
| } | |
| }); | |
| // Sort connected tools by avgY and execution order | |
| connectedTools.sort((a, b) => { | |
| const avgYDiff = a.avgY - b.avgY; | |
| if (Math.abs(avgYDiff) > 10) return avgYDiff; | |
| const orderA = toolOrder.get(a.tool.id) ?? 999; | |
| const orderB = toolOrder.get(b.tool.id) ?? 999; | |
| return orderA - orderB; | |
| }); | |
| // Sort unconnected tools by execution order | |
| unconnectedTools.sort((a, b) => { | |
| const orderA = toolOrder.get(a.tool.id) ?? 999; | |
| const orderB = toolOrder.get(b.tool.id) ?? 999; | |
| return orderA - orderB; | |
| }); | |
| // Separate above/below center like script.js | |
| const toolsAbove = connectedTools.filter((t) => t.avgY < centerY); | |
| const toolsBelow = connectedTools.filter((t) => t.avgY >= centerY); | |
| // Distribute unconnected tools to balance above/below | |
| unconnectedTools.forEach((toolData, index) => { | |
| if (index % 2 === 0) { | |
| if (toolsAbove.length <= toolsBelow.length) { | |
| toolsAbove.push(toolData); | |
| } else { | |
| toolsBelow.push(toolData); | |
| } | |
| } else { | |
| if (toolsBelow.length <= toolsAbove.length) { | |
| toolsBelow.push(toolData); | |
| } else { | |
| toolsAbove.push(toolData); | |
| } | |
| } | |
| }); | |
| toolsAbove.sort((a, b) => { | |
| const agentYA = Math.min( | |
| ...links | |
| .filter( | |
| (l) => | |
| l.type === "USES" && | |
| (typeof l.target === "string" ? l.target : l.target.id) === | |
| a.tool.id | |
| ) | |
| .map((l) => { | |
| const agentId = | |
| typeof l.source === "string" ? l.source : l.source.id; | |
| const agent = agents.find((ag) => ag.id === agentId); | |
| const agentLayout = agent ? layout[agent.id] : undefined; | |
| return agentLayout ? agentLayout.y : Infinity; | |
| }) | |
| ); | |
| const agentYB = Math.min( | |
| ...links | |
| .filter( | |
| (l) => | |
| l.type === "USES" && | |
| (typeof l.target === "string" ? l.target : l.target.id) === | |
| b.tool.id | |
| ) | |
| .map((l) => { | |
| const agentId = | |
| typeof l.source === "string" ? l.source : l.source.id; | |
| const agent = agents.find((ag) => ag.id === agentId); | |
| const agentLayout = agent ? layout[agent.id] : undefined; | |
| return agentLayout ? agentLayout.y : Infinity; | |
| }) | |
| ); | |
| return agentYA - agentYB; | |
| }); | |
| toolsBelow.sort((a, b) => { | |
| const agentYA = Math.max( | |
| ...links | |
| .filter( | |
| (l) => | |
| l.type === "USES" && | |
| (typeof l.target === "string" ? l.target : l.target.id) === | |
| a.tool.id | |
| ) | |
| .map((l) => { | |
| const agentId = | |
| typeof l.source === "string" ? l.source : l.source.id; | |
| const agent = agents.find((ag) => ag.id === agentId); | |
| const agentLayout = agent ? layout[agent.id] : undefined; | |
| return agentLayout ? agentLayout.y : -Infinity; | |
| }) | |
| ); | |
| const agentYB = Math.max( | |
| ...links | |
| .filter( | |
| (l) => | |
| l.type === "USES" && | |
| (typeof l.target === "string" ? l.target : l.target.id) === | |
| b.tool.id | |
| ) | |
| .map((l) => { | |
| const agentId = | |
| typeof l.source === "string" ? l.source : l.source.id; | |
| const agent = agents.find((ag) => ag.id === agentId); | |
| const agentLayout = agent ? layout[agent.id] : undefined; | |
| return agentLayout ? agentLayout.y : -Infinity; | |
| }) | |
| ); | |
| return agentYA - agentYB; // Smaller Y (closer to top) first | |
| }); | |
| // Separate connected and unconnected | |
| const connectedAbove = toolsAbove.filter((t) => | |
| unconnectedTools.every((u) => u.tool.id !== t.tool.id) | |
| ); | |
| const unconnectedAbove = toolsAbove.filter((t) => | |
| unconnectedTools.some((u) => u.tool.id === t.tool.id) | |
| ); | |
| const connectedBelow = toolsBelow.filter((t) => | |
| unconnectedTools.every((u) => u.tool.id !== t.tool.id) | |
| ); | |
| const unconnectedBelow = toolsBelow.filter((t) => | |
| unconnectedTools.some((u) => u.tool.id === t.tool.id) | |
| ); | |
| // Position tools with proper spacing | |
| const toolSpacing = 80; | |
| const baseDistanceAbove = Math.max( | |
| 100, | |
| (connectedAbove.length + unconnectedAbove.length) * 30 | |
| ); | |
| const baseDistanceBelow = Math.max( | |
| 100, | |
| (connectedBelow.length + unconnectedBelow.length) * 30 | |
| ); | |
| // Place connected tools above center | |
| connectedAbove.forEach(({ tool }, index) => { | |
| const yPosition = | |
| centerY - | |
| baseDistanceAbove - | |
| (connectedAbove.length - index) * toolSpacing; | |
| layout[tool.id] = { | |
| x: toolX, | |
| y: yPosition, | |
| }; | |
| }); | |
| // Place unconnected tools above center (farther out) | |
| unconnectedAbove.forEach(({ tool }, index) => { | |
| const yPosition = | |
| centerY - | |
| baseDistanceAbove - | |
| (connectedAbove.length + unconnectedAbove.length - index) * toolSpacing; | |
| layout[tool.id] = { | |
| x: toolX, | |
| y: yPosition, | |
| }; | |
| }); | |
| // Place connected tools below center | |
| connectedBelow.forEach(({ tool }, index) => { | |
| const yPosition = centerY + baseDistanceBelow + (index + 1) * toolSpacing; | |
| layout[tool.id] = { | |
| x: toolX, | |
| y: yPosition, | |
| }; | |
| }); | |
| // Place unconnected tools below center (farther out) | |
| unconnectedBelow.forEach(({ tool }, index) => { | |
| const yPosition = | |
| centerY + | |
| baseDistanceBelow + | |
| (connectedBelow.length + index + 1) * toolSpacing; | |
| layout[tool.id] = { | |
| x: toolX, | |
| y: yPosition, | |
| }; | |
| }); | |
| // Position Inputs based on consuming agents order | |
| const inputsByAgent = new Map<string, string[]>(); | |
| const processedInputs = new Set<string>(); | |
| inputs.forEach((input) => { | |
| const consumedByRelations = links.filter( | |
| (l) => | |
| l.type === "CONSUMED_BY" && | |
| (typeof l.source === "string" ? l.source : l.source.id) === input.id | |
| ); | |
| if (consumedByRelations.length > 0) { | |
| const firstRelation = consumedByRelations[0]; | |
| if (firstRelation?.target) { | |
| const primaryAgent = | |
| typeof firstRelation.target === "string" | |
| ? firstRelation.target | |
| : firstRelation.target.id; | |
| if (!inputsByAgent.has(primaryAgent)) { | |
| inputsByAgent.set(primaryAgent, []); | |
| } | |
| inputsByAgent.get(primaryAgent)!.push(input.id); | |
| processedInputs.add(input.id); | |
| } | |
| } | |
| }); | |
| // Sort inputs by their consuming agents' execution order | |
| const sortedInputs: string[] = []; | |
| // First add inputs connected to agents (sorted by agent order) | |
| const sortedAgentIds = [...agents] | |
| .sort( | |
| (a, b) => (agentOrder.get(a.id) ?? 999) - (agentOrder.get(b.id) ?? 999) | |
| ) | |
| .map((a) => a.id); | |
| sortedAgentIds.forEach((agentId) => { | |
| const agentInputs = inputsByAgent.get(agentId) || []; | |
| agentInputs.forEach((inputId) => sortedInputs.push(inputId)); | |
| }); | |
| // Then add orphan inputs | |
| inputs.forEach((input) => { | |
| if (!processedInputs.has(input.id)) { | |
| sortedInputs.push(input.id); | |
| } | |
| }); | |
| sortedInputs.forEach((inputId, i) => { | |
| const totalInputs = sortedInputs.length; | |
| const startY = centerY - ((totalInputs - 1) * nodeSpacing) / 2; | |
| layout[inputId] = { | |
| x: inputX, | |
| y: startY + i * nodeSpacing, | |
| }; | |
| }); | |
| // Position Humans based on intervention order - KEEP ORIGINAL ORDERING LOGIC | |
| const humansByTask = new Map<string, string[]>(); | |
| const processedHumans = new Set<string>(); | |
| humans.forEach((human) => { | |
| const intervenesRelations = links.filter( | |
| (l) => | |
| l.type === "INTERVENES" && | |
| (typeof l.source === "string" ? l.source : l.source.id) === human.id | |
| ); | |
| if (intervenesRelations.length > 0) { | |
| intervenesRelations.forEach((rel) => { | |
| const targetTaskId = | |
| typeof rel.target === "string" ? rel.target : rel.target?.id; | |
| if (targetTaskId) { | |
| if (!humansByTask.has(targetTaskId)) { | |
| humansByTask.set(targetTaskId, []); | |
| } | |
| humansByTask.get(targetTaskId)!.push(human.id); | |
| processedHumans.add(human.id); | |
| } | |
| }); | |
| } | |
| }); | |
| // Sort humans by their intervention tasks' execution order | |
| const sortedHumans: string[] = []; | |
| // First add humans connected to tasks (sorted by task order) | |
| const sortedTaskIds = [...tasks] | |
| .sort( | |
| (a, b) => (taskOrder.get(a.id) ?? 999) - (taskOrder.get(b.id) ?? 999) | |
| ) | |
| .map((t) => t.id); | |
| sortedTaskIds.forEach((taskId) => { | |
| const taskHumans = humansByTask.get(taskId) || []; | |
| taskHumans.forEach((humanId) => sortedHumans.push(humanId)); | |
| }); | |
| // Then add orphan humans | |
| humans.forEach((human) => { | |
| if (!processedHumans.has(human.id)) { | |
| sortedHumans.push(human.id); | |
| } | |
| }); | |
| sortedHumans.forEach((humanId, i) => { | |
| const totalHumans = sortedHumans.length; | |
| const startY = centerY - ((totalHumans - 1) * nodeSpacing) / 2; | |
| layout[humanId] = { | |
| x: humanX, | |
| y: startY + i * nodeSpacing, | |
| }; | |
| }); | |
| // Position Outputs based on task production order - KEEP ORIGINAL ORDERING LOGIC | |
| const outputsWithSpecialPositioning = new Set<string>(); | |
| // First, handle outputs that are consumed by agents (between task and agent) | |
| outputs.forEach((output) => { | |
| const consumedByRelations = links.filter( | |
| (l) => | |
| l.type === "CONSUMED_BY" && | |
| (typeof l.source === "string" ? l.source : l.source.id) === output.id | |
| ); | |
| if (consumedByRelations.length > 0) { | |
| const producingTaskRelation = links.find( | |
| (l) => | |
| l.type === "PRODUCES" && | |
| (typeof l.target === "string" ? l.target : l.target.id) === | |
| output.id | |
| ); | |
| if (producingTaskRelation) { | |
| const producingTaskId = | |
| typeof producingTaskRelation.source === "string" | |
| ? producingTaskRelation.source | |
| : producingTaskRelation.source.id; | |
| const producingTask = tasks.find((t) => t.id === producingTaskId); | |
| if (producingTask && layout[producingTask.id]) { | |
| consumedByRelations.forEach((consumedByRel) => { | |
| const consumingAgentId = | |
| typeof consumedByRel.target === "string" | |
| ? consumedByRel.target | |
| : consumedByRel.target.id; | |
| const consumingAgent = agents.find( | |
| (a) => a.id === consumingAgentId | |
| ); | |
| if ( | |
| consumingAgent && | |
| layout[consumingAgent.id] && | |
| layout[producingTask.id] | |
| ) { | |
| const taskPos = layout[producingTask.id]; | |
| const agentPos = layout[consumingAgent.id]; | |
| layout[output.id] = { | |
| x: (taskPos!.x + agentPos!.x) / 2, | |
| y: (taskPos!.y + agentPos!.y) / 2, | |
| }; | |
| outputsWithSpecialPositioning.add(output.id); | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| }); | |
| // Position remaining outputs based on task production order | |
| const remainingOutputs = outputs.filter( | |
| (output) => !outputsWithSpecialPositioning.has(output.id) | |
| ); | |
| const sortedOutputs = [...remainingOutputs].sort((a, b) => { | |
| const orderA = outputOrder.get(a.id) ?? 999; | |
| const orderB = outputOrder.get(b.id) ?? 999; | |
| return orderA - orderB; | |
| }); | |
| sortedOutputs.forEach((node, i) => { | |
| const totalOutputs = sortedOutputs.length; | |
| const startY = centerY - ((totalOutputs - 1) * nodeSpacing) / 2; | |
| layout[node.id] = { | |
| x: outputX, | |
| y: startY + i * nodeSpacing, | |
| }; | |
| }); | |
| return layout; | |
| } | |
| private getStructuredLayout(data: UniversalGraphData): any { | |
| // Check if this looks like an agent graph (has Task, Agent, Tool entities) | |
| const hasAgentEntities = data.nodes.some((n) => | |
| ["Task", "Agent", "Tool", "Input", "Output"].includes(n.type || "") | |
| ); | |
| if (!hasAgentEntities) { | |
| // Fall back to cola layout for non-agent graphs | |
| return this.getDefaultLayout(); | |
| } | |
| const positions = this.calculateStructuredLayout(data.nodes, data.links); | |
| return { | |
| name: "preset", | |
| positions: (node: any) => { | |
| const nodeId = node.data("id"); | |
| const pos = positions[nodeId]; | |
| return pos ? { x: pos.x, y: pos.y } : { x: 0, y: 0 }; | |
| }, | |
| fit: true, | |
| padding: 80, | |
| }; | |
| } | |
| private init() { | |
| console.log("Initializing CytoscapeGraphCore"); | |
| try { | |
| // Create Cytoscape instance | |
| this.cy = cytoscape({ | |
| container: this.container, | |
| elements: [], | |
| style: this.getDefaultStyle(), | |
| layout: this.getDefaultLayout(), | |
| minZoom: 0.1, | |
| maxZoom: 4, | |
| wheelSensitivity: 0.3, | |
| }); | |
| // Set up event handlers | |
| this.setupEventHandlers(); | |
| console.log("CytoscapeGraphCore initialization complete"); | |
| } catch (error) { | |
| console.error("Error initializing CytoscapeGraphCore:", error); | |
| throw error; | |
| } | |
| } | |
| // Helper function to determine optimal text color based on background luminance | |
| private getOptimalTextColor(backgroundColor: string): string { | |
| // Remove # if present | |
| const hex = backgroundColor.replace("#", ""); | |
| // Convert to RGB | |
| const r = parseInt(hex.substr(0, 2), 16); | |
| const g = parseInt(hex.substr(2, 2), 16); | |
| const b = parseInt(hex.substr(4, 2), 16); | |
| // Calculate luminance | |
| const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; | |
| // Return high contrast color | |
| return luminance > 0.5 ? "#1f2937" : "#ffffff"; | |
| } | |
| // Helper function to get modern gradient background | |
| private getModernNodeGradient(type: string): string { | |
| const gradients: Record<string, string> = { | |
| Agent: "linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%)", | |
| Tool: "linear-gradient(135deg, #10b981 0%, #059669 100%)", | |
| Task: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)", | |
| Input: "linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)", | |
| Output: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)", | |
| Human: "linear-gradient(135deg, #ec4899 0%, #db2777 100%)", | |
| Entity: "linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)", | |
| Person: "linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%)", | |
| Organization: "linear-gradient(135deg, #10b981 0%, #059669 100%)", | |
| Location: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)", | |
| Event: "linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)", | |
| Concept: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)", | |
| Product: "linear-gradient(135deg, #ec4899 0%, #db2777 100%)", | |
| }; | |
| return ( | |
| gradients[type] || "linear-gradient(135deg, #6b7280 0%, #4b5563 100%)" | |
| ); | |
| } | |
| // Helper function to darken a color by a specified amount | |
| private darkenColor(color: string, amount: number): string { | |
| const hex = color.replace("#", ""); | |
| const r = Math.max(0, parseInt(hex.substr(0, 2), 16) * (1 - amount)); | |
| const g = Math.max(0, parseInt(hex.substr(2, 2), 16) * (1 - amount)); | |
| const b = Math.max(0, parseInt(hex.substr(4, 2), 16) * (1 - amount)); | |
| const toHex = (c: number) => Math.round(c).toString(16).padStart(2, "0"); | |
| return `#${toHex(r)}${toHex(g)}${toHex(b)}`; | |
| } | |
| // Helper function to brighten a color by a specified amount | |
| private brightenColor(color: string, amount: number): string { | |
| const hex = color.replace("#", ""); | |
| const r = Math.min( | |
| 255, | |
| parseInt(hex.substr(0, 2), 16) + | |
| (255 - parseInt(hex.substr(0, 2), 16)) * amount | |
| ); | |
| const g = Math.min( | |
| 255, | |
| parseInt(hex.substr(2, 2), 16) + | |
| (255 - parseInt(hex.substr(2, 2), 16)) * amount | |
| ); | |
| const b = Math.min( | |
| 255, | |
| parseInt(hex.substr(4, 2), 16) + | |
| (255 - parseInt(hex.substr(4, 2), 16)) * amount | |
| ); | |
| const toHex = (c: number) => Math.round(c).toString(16).padStart(2, "0"); | |
| return `#${toHex(r)}${toHex(g)}${toHex(b)}`; | |
| } | |
| // Helper function to determine if a node type should use icons | |
| private isIconNodeType(type: string): boolean { | |
| return ["Agent", "Human", "Tool", "Task", "Input", "Output"].includes(type); | |
| } | |
| private getDefaultStyle(): any { | |
| return [ | |
| { | |
| selector: "node", | |
| style: { | |
| label: "data(label)", | |
| "text-valign": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, position text below | |
| if (this.isIconNodeType(nodeType)) { | |
| return "bottom"; | |
| } | |
| return "center"; | |
| }, | |
| "text-halign": "center", | |
| "text-margin-y": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, add margin to position text below icon | |
| if (this.isIconNodeType(nodeType)) { | |
| return 5; | |
| } | |
| return 0; | |
| }, | |
| color: (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use dark text for better visibility | |
| if (this.isIconNodeType(nodeType)) { | |
| return "#1f2937"; | |
| } | |
| return "#ffffff"; | |
| }, | |
| "font-size": "11px", | |
| "font-weight": "800", | |
| "text-outline-width": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use stronger outline for better contrast | |
| if (this.isIconNodeType(nodeType)) { | |
| return 2; | |
| } | |
| return 3; | |
| }, | |
| "text-outline-color": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use white outline for contrast | |
| if (this.isIconNodeType(nodeType)) { | |
| return "#ffffff"; | |
| } | |
| return "#000000"; | |
| }, | |
| "background-color": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, completely transparent background | |
| if (this.isIconNodeType(nodeType)) { | |
| return "rgba(0,0,0,0)"; | |
| } | |
| return getNodeColor(nodeType); | |
| }, | |
| "background-image": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // Only use icons for specific node types | |
| if (this.isIconNodeType(nodeType)) { | |
| return getNodeIcon(nodeType); | |
| } | |
| return "none"; | |
| }, | |
| "background-fit": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use contain to ensure full icon visibility | |
| if (this.isIconNodeType(nodeType)) { | |
| return "contain"; | |
| } | |
| return "cover"; | |
| }, | |
| "background-repeat": "no-repeat", | |
| "background-position-x": "50%", | |
| "background-position-y": "50%", | |
| "background-clip": "none", | |
| "background-opacity": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use background image opacity | |
| if (this.isIconNodeType(nodeType)) { | |
| return 1; | |
| } | |
| return 1; | |
| }, | |
| opacity: (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, full opacity for the image | |
| if (this.isIconNodeType(nodeType)) { | |
| return 1; | |
| } | |
| return 1; | |
| }, | |
| "overlay-color": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, no overlay | |
| if (this.isIconNodeType(nodeType)) { | |
| return "transparent"; | |
| } | |
| return "transparent"; | |
| }, | |
| "overlay-opacity": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, no overlay | |
| if (this.isIconNodeType(nodeType)) { | |
| return 0; | |
| } | |
| return 0; | |
| }, | |
| "border-width": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, zero border width | |
| if (this.isIconNodeType(nodeType)) { | |
| return 0; | |
| } | |
| return 2; | |
| }, | |
| "border-color": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, white border to match background | |
| if (this.isIconNodeType(nodeType)) { | |
| return "white"; | |
| } | |
| return "#ffffff"; | |
| }, | |
| "border-opacity": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, zero border opacity | |
| if (this.isIconNodeType(nodeType)) { | |
| return 0; | |
| } | |
| return 1; | |
| }, | |
| "text-transform": "none", | |
| "font-family": | |
| "Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif", | |
| shape: (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use ellipse to match circular gear design | |
| if (this.isIconNodeType(nodeType)) { | |
| return "ellipse"; | |
| } | |
| return "ellipse"; | |
| }, | |
| width: (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use much larger dimensions to make icons more prominent | |
| if (this.isIconNodeType(nodeType)) { | |
| return 50; | |
| } | |
| return getNodeSize(ele.data("type")); | |
| }, | |
| height: (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use much larger dimensions to make icons more prominent | |
| if (this.isIconNodeType(nodeType)) { | |
| return 50; | |
| } | |
| return getNodeSize(ele.data("type")); | |
| }, | |
| // Shadow properties removed - not supported by Cytoscape | |
| "text-outline-opacity": 0.8, | |
| "transition-property": | |
| "background-color, line-color, target-arrow-color, border-color, border-width, width, height, opacity", | |
| "transition-duration": "250ms", | |
| "transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)", | |
| }, | |
| }, | |
| { | |
| selector: "edge", | |
| style: { | |
| width: (ele: any) => { | |
| const relationType = ele.data("type"); | |
| const importance = ele.data("importance"); | |
| const baseValue = Math.max( | |
| 2, | |
| Math.sqrt(ele.data("value") || 1) * 1.5 | |
| ); | |
| const multiplier = getRelationWidth(relationType, importance); | |
| return baseValue * multiplier; | |
| }, | |
| "line-color": (ele: any) => { | |
| const relationType = ele.data("type"); | |
| return getRelationColor(relationType); | |
| }, | |
| "target-arrow-color": (ele: any) => { | |
| const relationType = ele.data("type"); | |
| return getRelationColor(relationType); | |
| }, | |
| "line-style": (ele: any) => { | |
| const relationType = ele.data("type"); | |
| return getRelationLineStyle(relationType); | |
| }, | |
| "target-arrow-shape": "triangle", | |
| "curve-style": "bezier", | |
| "arrow-scale": 1.8, | |
| opacity: 0.8, | |
| label: "data(label)", | |
| "font-size": "12px", | |
| "font-weight": "800", | |
| "font-family": | |
| "Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif", | |
| color: (ele: any) => { | |
| const relationType = ele.data("type"); | |
| return getRelationTextColor(relationType); | |
| }, | |
| "text-background-opacity": 0.9, | |
| "text-background-color": "rgba(255, 255, 255, 0.95)", | |
| "text-background-padding": "4px", | |
| "text-border-width": 0, | |
| "text-border-opacity": 0, | |
| "text-outline-width": 0, | |
| "text-margin-y": -18, | |
| "source-endpoint": "outside-to-node", | |
| "target-endpoint": "outside-to-node", | |
| "edge-text-rotation": "autorotate", | |
| "transition-property": | |
| "line-color, width, opacity, target-arrow-color", | |
| "transition-duration": "250ms", | |
| "transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)", | |
| }, | |
| }, | |
| { | |
| selector: "node:selected", | |
| style: { | |
| "border-width": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, add refined border on selection | |
| if (this.isIconNodeType(nodeType)) { | |
| return 3; | |
| } | |
| return 3; // Consistent modern border width | |
| }, | |
| "border-color": "#6366f1", // Purple theme color | |
| "border-style": "solid", | |
| "border-opacity": 1, | |
| "z-index": 999, | |
| "font-size": "12px", // Small font even when selected | |
| "text-outline-width": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| // For icon nodes, use refined outline | |
| if (this.isIconNodeType(nodeType)) { | |
| return 1; | |
| } | |
| return 2; | |
| }, | |
| "text-outline-color": "#6366f1", | |
| "background-color": (ele: any) => { | |
| const currentColor = ele.data("color"); | |
| if (currentColor) { | |
| return this.brightenColor(currentColor, 0.1); | |
| } | |
| return ele.style("background-color"); | |
| }, | |
| }, | |
| }, | |
| { | |
| selector: "edge:selected", | |
| style: { | |
| width: (ele: any) => { | |
| const relationType = ele.data("type"); | |
| const importance = ele.data("importance"); | |
| const baseValue = Math.sqrt(ele.data("value") || 1) * 1.5 + 1; | |
| const multiplier = getRelationWidth(relationType, importance); | |
| return baseValue * multiplier; | |
| }, | |
| "line-color": "#6366f1", // Purple theme color for selected edges | |
| "target-arrow-color": "#6366f1", | |
| opacity: 1, | |
| "z-index": 9, | |
| // Shadow properties removed - not supported by Cytoscape | |
| }, | |
| }, | |
| // Hover selectors removed - not supported by Cytoscape | |
| ]; | |
| } | |
| private getDefaultLayout(): any { | |
| return { | |
| name: "cola", | |
| animate: true, | |
| refresh: 1, | |
| maxSimulationTime: 4000, | |
| ungrabifyWhileSimulating: false, | |
| fit: true, | |
| padding: 80, | |
| nodeDimensionsIncludeLabels: true, | |
| randomize: false, | |
| avoidOverlap: true, | |
| handleDisconnected: true, | |
| convergenceThreshold: 0.01, | |
| nodeSpacing: (node: any) => { | |
| const nodeType = node.data("type"); | |
| // Larger spacing for icon nodes to prevent overlaps | |
| if (this.isIconNodeType(nodeType)) { | |
| return 120; | |
| } | |
| return 80; | |
| }, | |
| flow: { axis: "x", minSeparation: 150 }, // Left-to-right workflow flow | |
| alignment: (node: any) => { | |
| const nodeType = node.data("type"); | |
| // Create workflow-based alignment: Input → Task → Agent → Tool → Output | |
| switch (nodeType?.toLowerCase()) { | |
| case "input": | |
| return { x: -2, y: 0 }; // Far left | |
| case "task": | |
| return { x: -1, y: 0 }; // Left-center | |
| case "agent": | |
| return { x: 0, y: 0 }; // Center | |
| case "tool": | |
| return { x: 1, y: 0 }; // Right-center | |
| case "output": | |
| return { x: 2, y: 0 }; // Far right | |
| case "human": | |
| return { x: 0, y: -1 }; // Top-center (supervisory role) | |
| default: | |
| return undefined; // Let cola position these naturally | |
| } | |
| }, | |
| gapInequalities: undefined, | |
| centerGraph: true, | |
| edgeLength: this.config.linkDistance || 180, | |
| edgeSymDiffLength: undefined, | |
| edgeJaccardLength: undefined, | |
| unconstrIter: undefined, | |
| userConstIter: undefined, | |
| allConstIter: undefined, | |
| infinite: false, | |
| }; | |
| } | |
| private setupEventHandlers() { | |
| if (!this.cy) return; | |
| // Node selection | |
| this.cy.on("tap", "node", (event) => { | |
| const node = event.target; | |
| this.selectNode(this.nodeToUniversalNode(node)); | |
| }); | |
| // Edge selection | |
| this.cy.on("tap", "edge", (event) => { | |
| const edge = event.target; | |
| this.selectLink(this.edgeToUniversalLink(edge)); | |
| }); | |
| // Background click - clear selection | |
| this.cy.on("tap", (event) => { | |
| if (event.target === this.cy) { | |
| this.clearSelection(); | |
| } | |
| }); | |
| // Hover effects | |
| this.cy.on("mouseover", "node", (event) => { | |
| event.target.addClass("hover"); | |
| }); | |
| this.cy.on("mouseout", "node", (event) => { | |
| event.target.removeClass("hover"); | |
| }); | |
| this.cy.on("mouseover", "edge", (event) => { | |
| event.target.addClass("hover"); | |
| }); | |
| this.cy.on("mouseout", "edge", (event) => { | |
| event.target.removeClass("hover"); | |
| }); | |
| // Drag collision detection | |
| this.cy.on("drag", "node", (event) => { | |
| this.handleNodeDragCollision(event.target); | |
| this.updateZoneBoxesAfterDrag(); | |
| }); | |
| this.cy.on("dragfree", "node", (event) => { | |
| this.handleNodeDragEnd(event.target); | |
| }); | |
| } | |
| private nodeToUniversalNode(node: NodeSingular): UniversalNode { | |
| const data = node.data(); | |
| return { | |
| id: data.id, | |
| name: data.name || data.label, | |
| label: data.label, | |
| type: data.type, | |
| x: node.position("x"), | |
| y: node.position("y"), | |
| properties: data.properties || {}, | |
| importance: data.importance, | |
| risk: data.risk, | |
| raw_prompt: data.raw_prompt, | |
| raw_prompt_ref: data.raw_prompt_ref, | |
| raw_text_ref: data.raw_text_ref, | |
| }; | |
| } | |
| private edgeToUniversalLink(edge: EdgeSingular): UniversalLink { | |
| const data = edge.data(); | |
| return { | |
| id: data.id, | |
| source: data.source, | |
| target: data.target, | |
| type: data.type, | |
| label: data.label, | |
| value: data.value || 1, | |
| properties: data.properties || {}, | |
| importance: data.importance, | |
| interaction_prompt: data.interaction_prompt, | |
| interaction_prompt_ref: data.interaction_prompt_ref, | |
| }; | |
| } | |
| public updateGraph( | |
| data: UniversalGraphData, | |
| _animate: boolean = true, | |
| failures: any[] = [] | |
| ): void { | |
| if (!this.cy) { | |
| console.error( | |
| "CytoscapeGraphCore: Cannot update graph - Cytoscape not initialized" | |
| ); | |
| return; | |
| } | |
| try { | |
| // Analyze connectivity before adding elements | |
| const connectivityInfo = this.analyzeConnectivity(data); | |
| // Group affected elements by execution error | |
| const affectedElementsByError = new Map<string, Set<string>>(); | |
| failures.forEach((failure: any, index: number) => { | |
| if (failure.affected_id || (failure as any).matchedElement?.id) { | |
| const affectedId = | |
| failure.affected_id || (failure as any).matchedElement?.id; | |
| const errorId = | |
| failure.execution_id || failure.error_id || `error_${index}`; | |
| if (!affectedElementsByError.has(errorId)) { | |
| affectedElementsByError.set(errorId, new Set<string>()); | |
| } | |
| affectedElementsByError.get(errorId)!.add(affectedId); | |
| } | |
| }); | |
| // Convert data to Cytoscape format with connectivity information | |
| const elements = this.convertToElements(data, connectivityInfo); | |
| // Remove all existing elements | |
| this.cy.elements().remove(); | |
| // Add new elements | |
| this.cy.add(elements); | |
| // Apply structured layout for agent graphs - use execution order positioning | |
| const layoutConfig = this.getStructuredLayout(data); | |
| const layout = this.cy.layout(layoutConfig); | |
| // Store current data for later access | |
| this.currentData = data; | |
| layout.one("layoutstop", () => { | |
| this.addVisualZones(connectivityInfo, affectedElementsByError); | |
| }); | |
| layout.run(); | |
| // Graph updated successfully | |
| } catch (error) { | |
| console.error("❌ CytoscapeGraphCore: Error updating graph:", error); | |
| throw error; | |
| } | |
| } | |
| private convertToElements( | |
| data: UniversalGraphData, | |
| _connectivityInfo?: { | |
| isolatedNodes: Set<string>; | |
| connectedNodes: Set<string>; | |
| mainComponent: Set<string>; | |
| } | |
| ) { | |
| const elements: any[] = []; | |
| // Convert nodes | |
| data.nodes.forEach((node) => { | |
| elements.push({ | |
| group: "nodes", | |
| data: { | |
| id: node.id, | |
| label: node.name || node.label || node.id, | |
| name: node.name, | |
| type: node.type, | |
| properties: node.properties || {}, | |
| importance: node.importance, | |
| risk: node.risk, | |
| raw_prompt: node.raw_prompt, | |
| raw_prompt_ref: node.raw_prompt_ref, | |
| raw_text_ref: node.raw_text_ref, | |
| }, | |
| }); | |
| }); | |
| // Convert links | |
| data.links.forEach((link) => { | |
| const sourceId = | |
| typeof link.source === "string" ? link.source : link.source.id; | |
| const targetId = | |
| typeof link.target === "string" ? link.target : link.target.id; | |
| elements.push({ | |
| group: "edges", | |
| data: { | |
| id: link.id, | |
| source: sourceId, | |
| target: targetId, | |
| label: link.label || link.type, | |
| type: link.type, | |
| value: link.value || 1, | |
| properties: link.properties || {}, | |
| importance: link.importance, | |
| interaction_prompt: link.interaction_prompt, | |
| interaction_prompt_ref: link.interaction_prompt_ref, | |
| }, | |
| selectable: true, | |
| grabbable: false, | |
| }); | |
| }); | |
| return elements; | |
| } | |
| public zoomIn() { | |
| if (this.cy) { | |
| this.cy.zoom(this.cy.zoom() * 1.5); | |
| this.cy.center(); | |
| } | |
| } | |
| public zoomOut() { | |
| if (this.cy) { | |
| this.cy.zoom(this.cy.zoom() / 1.5); | |
| this.cy.center(); | |
| } | |
| } | |
| public resetZoom() { | |
| if (this.cy) { | |
| this.cy.fit(); | |
| } | |
| } | |
| public resize(width: number, height: number) { | |
| this.config.width = width; | |
| this.config.height = height; | |
| if (this.cy) { | |
| this.cy.resize(); | |
| this.cy.fit(); | |
| } | |
| } | |
| public destroy() { | |
| if (this.cy) { | |
| try { | |
| // Remove all event listeners first | |
| this.cy.removeAllListeners(); | |
| // Destroy the Cytoscape instance | |
| this.cy.destroy(); | |
| } catch (error) { | |
| console.warn("Error during Cytoscape destroy:", error); | |
| } finally { | |
| this.cy = null; | |
| } | |
| } | |
| // Clear the container in a React-safe way | |
| if (this.container) { | |
| try { | |
| // Remove the active marker and set cleared marker | |
| this.container.removeAttribute("data-cytoscape-active"); | |
| this.container.setAttribute("data-cytoscape-cleared", "true"); | |
| // Don't directly manipulate innerHTML - let React handle it | |
| } catch (error) { | |
| console.warn("Error clearing container:", error); | |
| } | |
| } | |
| } | |
| public getCurrentData(): UniversalGraphData { | |
| return this.currentData; | |
| } | |
| public getCytoscape(): Core | null { | |
| return this.cy; | |
| } | |
| public selectNodeById(nodeId: string) { | |
| if (!this.cy) return; | |
| const node = this.cy.getElementById(nodeId); | |
| if (node.length > 0) { | |
| this.selectNode(this.nodeToUniversalNode(node)); | |
| } | |
| } | |
| private selectNode(node: UniversalNode) { | |
| this.selectedElement = { type: "node", id: node.id }; | |
| this.updateSelectionStyling(); | |
| if (this.callbacks.onNodeSelect) { | |
| this.callbacks.onNodeSelect(node); | |
| } | |
| } | |
| private selectLink(link: UniversalLink) { | |
| this.selectedElement = { type: "link", id: link.id }; | |
| this.updateSelectionStyling(); | |
| if (this.callbacks.onLinkSelect) { | |
| this.callbacks.onLinkSelect(link); | |
| } | |
| } | |
| private clearSelection() { | |
| this.selectedElement = null; | |
| this.updateSelectionStyling(); | |
| if (this.callbacks.onClearSelection) { | |
| this.callbacks.onClearSelection(); | |
| } | |
| } | |
| private updateSelectionStyling() { | |
| if (!this.cy) return; | |
| // Clear all selections first | |
| this.cy.elements().unselect(); | |
| if (this.selectedElement) { | |
| const element = this.cy.getElementById(this.selectedElement.id); | |
| if (element.length > 0) { | |
| element.select(); | |
| } | |
| } | |
| } | |
| private handleNodeDragCollision(draggedNode: NodeSingular) { | |
| if (!this.cy) return; | |
| const draggedPos = draggedNode.position(); | |
| const draggedSize = this.getNodeSize(draggedNode); | |
| // Check all other nodes for collisions | |
| this.cy.nodes().forEach((otherNode: NodeSingular) => { | |
| if (otherNode.id() === draggedNode.id()) return; | |
| const otherPos = otherNode.position(); | |
| const otherSize = this.getNodeSize(otherNode); | |
| const distance = Math.sqrt( | |
| Math.pow(draggedPos.x - otherPos.x, 2) + | |
| Math.pow(draggedPos.y - otherPos.y, 2) | |
| ); | |
| const requiredDistance = (draggedSize + otherSize) / 2 + 30; | |
| if (distance < requiredDistance) { | |
| // Calculate repulsion vector | |
| const dx = draggedPos.x - otherPos.x; | |
| const dy = draggedPos.y - otherPos.y; | |
| const length = Math.sqrt(dx * dx + dy * dy); | |
| if (length > 0) { | |
| // Normalize and apply repulsion | |
| const pushDistance = requiredDistance - distance; | |
| const pushX = (dx / length) * pushDistance * 0.5; | |
| const pushY = (dy / length) * pushDistance * 0.5; | |
| // Move the other node away | |
| otherNode.position({ | |
| x: otherPos.x - pushX, | |
| y: otherPos.y - pushY, | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| private handleNodeDragEnd(_draggedNode: NodeSingular) { | |
| this.updateZoneBoxesAfterDrag(); | |
| } | |
| private updateZoneBoxesAfterDrag() { | |
| if (!this.cy) return; | |
| if (!this.currentData) return; | |
| const connectivityInfo = this.analyzeConnectivity(this.currentData); | |
| this.cy.remove(".zone-background"); | |
| // Calculate bounds for all non-isolated nodes | |
| const allNonIsolatedIds = new Set<string>(); | |
| this.cy.nodes().forEach(node => { | |
| const nodeId = node.id(); | |
| if (!connectivityInfo.isolatedNodes.has(nodeId) && !node.hasClass('zone-background')) { | |
| allNonIsolatedIds.add(nodeId); | |
| } | |
| }); | |
| const mainComponentBounds = this.calculateZoneBounds(allNonIsolatedIds); | |
| const isolatedNodesBounds = this.calculateZoneBounds( | |
| connectivityInfo.isolatedNodes | |
| ); | |
| if (mainComponentBounds && allNonIsolatedIds.size > 0) { | |
| this.cy.add({ | |
| group: "nodes", | |
| data: { | |
| id: "main-zone-bg", | |
| label: "", | |
| type: "zone-background", | |
| }, | |
| classes: "zone-background main-zone", | |
| position: { | |
| x: mainComponentBounds.centerX, | |
| y: mainComponentBounds.centerY + 10, | |
| }, | |
| locked: true, | |
| grabbable: false, | |
| selectable: false, | |
| }); | |
| } | |
| if (isolatedNodesBounds && connectivityInfo.isolatedNodes.size > 0) { | |
| this.cy.add({ | |
| group: "nodes", | |
| data: { | |
| id: "isolated-zone-bg", | |
| label: "", | |
| type: "zone-background", | |
| }, | |
| classes: "zone-background isolated-zone", | |
| position: { | |
| x: isolatedNodesBounds.centerX, | |
| y: isolatedNodesBounds.centerY + 10, | |
| }, | |
| locked: true, | |
| grabbable: false, | |
| selectable: false, | |
| }); | |
| } | |
| // Apply zone background styles | |
| this.cy | |
| .style() | |
| .selector(".zone-background.main-zone") | |
| .style({ | |
| width: mainComponentBounds ? mainComponentBounds.width + 90 : 200, | |
| height: mainComponentBounds ? mainComponentBounds.height + 90 : 200, | |
| "background-color": "#10b981", | |
| "background-opacity": 0.1, | |
| "border-width": 2, | |
| "border-color": "#10b981", | |
| "border-opacity": 0.3, | |
| "border-style": "solid", | |
| shape: "round-rectangle", | |
| "z-index": -1, | |
| events: "no", | |
| }) | |
| .selector(".zone-background.isolated-zone") | |
| .style({ | |
| width: isolatedNodesBounds ? isolatedNodesBounds.width + 90 : 200, | |
| height: isolatedNodesBounds ? isolatedNodesBounds.height + 90 : 200, | |
| "background-color": "#f59e0b", | |
| "background-opacity": 0.1, | |
| "border-width": 2, | |
| "border-color": "#f59e0b", | |
| "border-opacity": 0.3, | |
| "border-style": "dashed", | |
| shape: "round-rectangle", | |
| "z-index": -1, | |
| events: "no", | |
| }) | |
| .update(); | |
| } | |
| private getNodeSize(node: NodeSingular): number { | |
| const nodeType = node.data("type"); | |
| if (this.isIconNodeType(nodeType)) { | |
| return 50; // Size for icon nodes | |
| } | |
| return getNodeSize(nodeType) || 60; // Default size for other nodes | |
| } | |
| private analyzeConnectivity(data: UniversalGraphData): { | |
| isolatedNodes: Set<string>; | |
| connectedNodes: Set<string>; | |
| mainComponent: Set<string>; | |
| } { | |
| const connectedNodes = new Set<string>(); | |
| const allNodeIds = new Set(data.nodes.map((node) => node.id)); | |
| // Find all nodes that have connections | |
| data.links.forEach((link) => { | |
| const sourceId = | |
| typeof link.source === "string" ? link.source : link.source.id; | |
| const targetId = | |
| typeof link.target === "string" ? link.target : link.target.id; | |
| connectedNodes.add(sourceId); | |
| connectedNodes.add(targetId); | |
| }); | |
| // Find isolated nodes (nodes with no connections) | |
| const isolatedNodes = new Set<string>(); | |
| allNodeIds.forEach((nodeId) => { | |
| if (!connectedNodes.has(nodeId)) { | |
| isolatedNodes.add(nodeId); | |
| } | |
| }); | |
| // Find the main component (largest connected component) | |
| const mainComponent = this.findLargestConnectedComponent( | |
| data, | |
| connectedNodes | |
| ); | |
| return { | |
| isolatedNodes, | |
| connectedNodes, | |
| mainComponent, | |
| }; | |
| } | |
| private findLargestConnectedComponent( | |
| data: UniversalGraphData, | |
| connectedNodes: Set<string> | |
| ): Set<string> { | |
| const adjacencyList = new Map<string, Set<string>>(); | |
| // Build adjacency list | |
| data.links.forEach((link) => { | |
| const sourceId = | |
| typeof link.source === "string" ? link.source : link.source.id; | |
| const targetId = | |
| typeof link.target === "string" ? link.target : link.target.id; | |
| if (!adjacencyList.has(sourceId)) adjacencyList.set(sourceId, new Set()); | |
| if (!adjacencyList.has(targetId)) adjacencyList.set(targetId, new Set()); | |
| adjacencyList.get(sourceId)!.add(targetId); | |
| adjacencyList.get(targetId)!.add(sourceId); | |
| }); | |
| // Find connected components using DFS | |
| const visited = new Set<string>(); | |
| const components: Set<string>[] = []; | |
| connectedNodes.forEach((nodeId) => { | |
| if (!visited.has(nodeId)) { | |
| const component = new Set<string>(); | |
| this.dfsComponent(nodeId, adjacencyList, visited, component); | |
| components.push(component); | |
| } | |
| }); | |
| // Return the largest component | |
| return components.reduce( | |
| (largest, current) => (current.size > largest.size ? current : largest), | |
| new Set<string>() | |
| ); | |
| } | |
| private dfsComponent( | |
| nodeId: string, | |
| adjacencyList: Map<string, Set<string>>, | |
| visited: Set<string>, | |
| component: Set<string> | |
| ) { | |
| visited.add(nodeId); | |
| component.add(nodeId); | |
| const neighbors = adjacencyList.get(nodeId); | |
| if (neighbors) { | |
| neighbors.forEach((neighbor) => { | |
| if (!visited.has(neighbor)) { | |
| this.dfsComponent(neighbor, adjacencyList, visited, component); | |
| } | |
| }); | |
| } | |
| } | |
| private getZoneAwareLayout(connectivityInfo: { | |
| isolatedNodes: Set<string>; | |
| connectedNodes: Set<string>; | |
| mainComponent: Set<string>; | |
| }): any { | |
| return { | |
| name: "cola", | |
| animate: true, | |
| refresh: 1, | |
| maxSimulationTime: 4000, | |
| ungrabifyWhileSimulating: false, | |
| fit: true, | |
| padding: 80, | |
| nodeDimensionsIncludeLabels: true, | |
| randomize: false, | |
| avoidOverlap: true, | |
| handleDisconnected: true, | |
| convergenceThreshold: 0.01, | |
| nodeSpacing: (node: any) => { | |
| const nodeType = node.data("type"); | |
| const nodeId = node.data("id"); | |
| // Larger spacing for isolated nodes | |
| if (connectivityInfo.isolatedNodes.has(nodeId)) { | |
| return 150; | |
| } | |
| // Different spacing for icon vs regular nodes | |
| if (this.isIconNodeType(nodeType)) { | |
| return 120; | |
| } | |
| return 80; | |
| }, | |
| flow: { axis: "x", minSeparation: 150 }, // Left-to-right workflow flow | |
| alignment: (node: any) => { | |
| const nodeId = node.data("id"); | |
| const nodeType = node.data("type"); | |
| // Group isolated nodes separately at the top | |
| if (connectivityInfo.isolatedNodes.has(nodeId)) { | |
| return { x: 0, y: -2 }; // Top area for isolated nodes | |
| } | |
| // Create workflow-based alignment for connected nodes: Input → Task → Agent → Tool → Output | |
| switch (nodeType?.toLowerCase()) { | |
| case "input": | |
| return { x: -2, y: 0 }; // Far left | |
| case "task": | |
| return { x: -1, y: 0 }; // Left-center | |
| case "agent": | |
| return { x: 0, y: 0 }; // Center | |
| case "tool": | |
| return { x: 1, y: 0 }; // Right-center | |
| case "output": | |
| return { x: 2, y: 0 }; // Far right | |
| case "human": | |
| return { x: 0, y: -1 }; // Top-center (supervisory role) | |
| default: | |
| return undefined; // Let cola position these naturally | |
| } | |
| }, | |
| gapInequalities: undefined, | |
| centerGraph: true, | |
| edgeLength: this.config.linkDistance || 180, | |
| edgeSymDiffLength: undefined, | |
| edgeJaccardLength: undefined, | |
| unconstrIter: undefined, | |
| userConstIter: undefined, | |
| allConstIter: undefined, | |
| infinite: false, | |
| }; | |
| } | |
| // Define modern color palette aligned with design system | |
| private readonly ZONE_COLORS = { | |
| primary: { | |
| bg: "rgba(99, 102, 241, 0.08)", | |
| border: "#6366f1", | |
| borderAlpha: "rgba(99, 102, 241, 0.6)", | |
| accent: "#6366f1", | |
| }, | |
| success: { | |
| bg: "rgba(16, 185, 129, 0.08)", | |
| border: "#10b981", | |
| borderAlpha: "rgba(16, 185, 129, 0.6)", | |
| accent: "#10b981", | |
| }, | |
| warning: { | |
| bg: "rgba(245, 158, 11, 0.08)", | |
| border: "#f59e0b", | |
| borderAlpha: "rgba(245, 158, 11, 0.7)", | |
| accent: "#f59e0b", | |
| }, | |
| danger: { | |
| bg: "rgba(239, 68, 68, 0.08)", | |
| border: "#ef4444", | |
| borderAlpha: "rgba(239, 68, 68, 0.8)", | |
| accent: "#ef4444", | |
| }, | |
| }; | |
| private addVisualZones( | |
| connectivityInfo: { | |
| isolatedNodes: Set<string>; | |
| connectedNodes: Set<string>; | |
| mainComponent: Set<string>; | |
| }, | |
| affectedElementsByError: Map<string, Set<string>> = new Map() | |
| ) { | |
| if (!this.cy) return; | |
| // Add modern visual styling with glass-morphism effects | |
| this.cy | |
| .style() | |
| .selector(`node[id][?isolated]`) | |
| .style({ | |
| "border-width": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| if (this.isIconNodeType(nodeType)) { | |
| return 3; // Refined border thickness | |
| } | |
| return 2; | |
| }, | |
| "border-color": this.ZONE_COLORS.warning.border, | |
| "border-style": "dashed", | |
| "border-opacity": 0.8, | |
| "text-outline-color": "#f59e0b", | |
| "text-outline-width": 1, | |
| }) | |
| .selector(`node[id][?affected]`) | |
| .style({ | |
| "border-width": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| if (this.isIconNodeType(nodeType)) { | |
| return 4; // Prominent but refined border | |
| } | |
| return 3; | |
| }, | |
| "border-color": this.ZONE_COLORS.danger.border, | |
| "border-style": "solid", | |
| "border-opacity": 1, // Full opacity for prominence | |
| "text-outline-color": "#dc2626", | |
| "text-outline-width": 1, | |
| }) | |
| .selector(`node[zone = 'main']`) | |
| .style({ | |
| "border-width": (ele: any) => { | |
| const nodeType = ele.data("type"); | |
| if (this.isIconNodeType(nodeType)) { | |
| return 2; // Subtle border for main workflow nodes | |
| } | |
| return 1; | |
| }, | |
| "border-color": this.ZONE_COLORS.success.border, | |
| "border-style": "solid", | |
| "border-opacity": 0.6, | |
| "text-outline-color": "#ffffff", | |
| "text-outline-width": 2, | |
| }) | |
| .update(); | |
| // Mark isolated nodes with custom data and visual styling | |
| connectivityInfo.isolatedNodes.forEach((nodeId) => { | |
| const node = this.cy!.getElementById(nodeId); | |
| if (node.length > 0) { | |
| node.data("isolated", true); | |
| node.data("zone", "isolated"); | |
| } | |
| }); | |
| // Mark main component nodes | |
| connectivityInfo.mainComponent.forEach((nodeId) => { | |
| const node = this.cy!.getElementById(nodeId); | |
| if (node.length > 0) { | |
| node.data("zone", "main"); | |
| } | |
| }); | |
| // Mark affected nodes and edges with failure highlighting | |
| let errorIndex = 0; | |
| affectedElementsByError.forEach((affectedElementIds, errorId) => { | |
| affectedElementIds.forEach((nodeId) => { | |
| const node = this.cy!.getElementById(nodeId); | |
| if (node.length > 0) { | |
| node.data("affected", true); | |
| node.data("zone", "affected"); | |
| node.data("errorId", errorId); | |
| node.data("errorIndex", errorIndex); | |
| // Mark all connected edges as affected and style them | |
| const connectedEdges = node.connectedEdges(); | |
| connectedEdges.forEach((edge) => { | |
| edge.data("affected", true); | |
| edge.data("errorId", errorId); | |
| // Apply red styling to affected edges | |
| edge.style({ | |
| "line-color": this.ZONE_COLORS.danger.border, | |
| "target-arrow-color": this.ZONE_COLORS.danger.border, | |
| width: Math.max(3, parseFloat(edge.style("width")) * 1.5), | |
| opacity: 1, | |
| "z-index": 10, | |
| }); | |
| }); | |
| } | |
| }); | |
| errorIndex++; | |
| }); | |
| // Add background shadow areas for visual clustering | |
| this.addZoneBackgrounds(connectivityInfo); | |
| // Log zone information for debugging | |
| console.log("🎨 Visual zones applied:", { | |
| isolatedNodes: connectivityInfo.isolatedNodes.size, | |
| isolatedNodeIds: Array.from(connectivityInfo.isolatedNodes), | |
| mainComponent: connectivityInfo.mainComponent.size, | |
| mainComponentIds: Array.from(connectivityInfo.mainComponent), | |
| totalConnected: connectivityInfo.connectedNodes.size, | |
| }); | |
| } | |
| private addZoneBackgrounds(connectivityInfo: { | |
| isolatedNodes: Set<string>; | |
| connectedNodes: Set<string>; | |
| mainComponent: Set<string>; | |
| }) { | |
| if (!this.cy) return; | |
| // Remove existing zone backgrounds | |
| this.cy.remove(".zone-background"); | |
| // Calculate bounds for all non-isolated nodes | |
| const allNodes = new Set<string>(); | |
| this.cy.nodes().forEach(node => { | |
| const nodeId = node.id(); | |
| if (!connectivityInfo.isolatedNodes.has(nodeId)) { | |
| allNodes.add(nodeId); | |
| } | |
| }); | |
| const mainBounds = this.calculateZoneBounds(allNodes); | |
| const isolatedBounds = this.calculateZoneBounds( | |
| connectivityInfo.isolatedNodes | |
| ); | |
| // Add main and isolated zones | |
| const zones = [ | |
| { | |
| bounds: mainBounds, | |
| id: "main-zone-bg", | |
| className: "main-zone", | |
| size: allNodes.size, | |
| }, | |
| { | |
| bounds: isolatedBounds, | |
| id: "isolated-zone-bg", | |
| className: "isolated-zone", | |
| size: connectivityInfo.isolatedNodes.size, | |
| }, | |
| ]; | |
| zones.forEach(({ bounds, id, className, size }) => { | |
| if (bounds && size > 0) { | |
| const nodeData: any = { | |
| group: "nodes", | |
| data: { id, label: "", type: "zone-background" }, | |
| classes: `zone-background ${className}`, | |
| position: { x: bounds.centerX, y: bounds.centerY + 10 }, | |
| locked: true, | |
| grabbable: false, | |
| selectable: false, | |
| }; | |
| this.cy!.add(nodeData); | |
| } | |
| }); | |
| // Apply zone background styles | |
| this.cy | |
| .style() | |
| .selector(".zone-background.main-zone") | |
| .style({ | |
| width: mainBounds ? mainBounds.width + 90 : 200, | |
| height: mainBounds ? mainBounds.height + 90 : 200, | |
| "background-color": "#10b981", | |
| "background-opacity": 0.1, | |
| "border-width": 2, | |
| "border-color": "#10b981", | |
| "border-opacity": 0.3, | |
| "border-style": "solid", | |
| shape: "round-rectangle", | |
| "z-index": -1, | |
| events: "no", | |
| }) | |
| .selector(".zone-background.isolated-zone") | |
| .style({ | |
| width: isolatedBounds ? isolatedBounds.width + 90 : 200, | |
| height: isolatedBounds ? isolatedBounds.height + 90 : 200, | |
| "background-color": "#f59e0b", | |
| "background-opacity": 0.1, | |
| "border-width": 2, | |
| "border-color": "#f59e0b", | |
| "border-opacity": 0.3, | |
| "border-style": "dashed", | |
| shape: "round-rectangle", | |
| "z-index": -1, | |
| events: "no", | |
| }) | |
| .update(); | |
| } | |
| private calculateZoneBounds(nodeIds: Set<string>): { | |
| centerX: number; | |
| centerY: number; | |
| width: number; | |
| height: number; | |
| } | null { | |
| if (!this.cy || nodeIds.size === 0) return null; | |
| let minX = Infinity, | |
| maxX = -Infinity; | |
| let minY = Infinity, | |
| maxY = -Infinity; | |
| nodeIds.forEach((nodeId) => { | |
| const node = this.cy!.getElementById(nodeId); | |
| if (node.length > 0) { | |
| const pos = node.position(); | |
| const width = node.width() || 60; | |
| const height = node.height() || 60; | |
| minX = Math.min(minX, pos.x - width / 2); | |
| maxX = Math.max(maxX, pos.x + width / 2); | |
| minY = Math.min(minY, pos.y - height / 2); | |
| maxY = Math.max(maxY, pos.y + height / 2); | |
| } | |
| }); | |
| if (minX === Infinity) return null; | |
| return { | |
| centerX: (minX + maxX) / 2, | |
| centerY: (minY + maxY) / 2, | |
| width: maxX - minX, | |
| height: maxY - minY, | |
| }; | |
| } | |
| } | |