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; toolOrder: Map; agentOrder: Map; outputOrder: Map; } 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(); const toolOrder = new Map(); const agentOrder = new Map(); const outputOrder = new Map(); 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(); const allTasks = nodes.filter((n) => n.type === "Task"); // Find tasks involved in NEXT relations const tasksInNextRelations = new Set(); 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(); 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(); 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(); 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(); 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(); 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(); const processedInputs = new Set(); 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(); const processedHumans = new Set(); 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(); // 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 = { 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>(); 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()); } 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; connectedNodes: Set; mainComponent: Set; } ) { 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(); 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; connectedNodes: Set; mainComponent: Set; } { const connectedNodes = new Set(); 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(); 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 ): Set { const adjacencyList = new Map>(); // 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(); const components: Set[] = []; connectedNodes.forEach((nodeId) => { if (!visited.has(nodeId)) { const component = new Set(); 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() ); } private dfsComponent( nodeId: string, adjacencyList: Map>, visited: Set, component: Set ) { 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; connectedNodes: Set; mainComponent: Set; }): 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; connectedNodes: Set; mainComponent: Set; }, affectedElementsByError: Map> = 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; connectedNodes: Set; mainComponent: Set; }) { if (!this.cy) return; // Remove existing zone backgrounds this.cy.remove(".zone-background"); // Calculate bounds for all non-isolated nodes const allNodes = new Set(); 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): { 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, }; } }