AgentGraph / frontend /src /lib /cytoscape-graph-core.ts
wu981526092's picture
add
836f1d8
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,
};
}
}