mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-05 17:22:36 +00:00
2466 lines
81 KiB
JavaScript
2466 lines
81 KiB
JavaScript
"use strict";
|
|
|
|
const CONSTANTS = {
|
|
NODE: {
|
|
BASE_WIDTH: 220,
|
|
BASE_HEIGHT: 100,
|
|
BORDER_RADIUS: 20,
|
|
TEXT_SIZE: 13,
|
|
TEXT_PADDING: 16,
|
|
TEXT_BG_RADIUS: 6,
|
|
HOVER_SCALE: 1.00,
|
|
PRESSED_SCALE: 1.16,
|
|
SELECTED_SCALE: 1.05,
|
|
},
|
|
EDGE: {
|
|
DEFAULT_WIDTH: 2,
|
|
HIGHLIGHTED_WIDTH: 8,
|
|
ANIMATION_DURATION: 300,
|
|
DEFAULT_SHADOW_SIZE: 4,
|
|
HIGHLIGHTED_SHADOW_SIZE: 20,
|
|
},
|
|
ANIMATION: {
|
|
DURATION: 300,
|
|
EASE_OUT_CUBIC: (t) => 1 - Math.pow(1 - t, 3),
|
|
},
|
|
NETWORK: {
|
|
STABILIZATION_ITERATIONS: 300,
|
|
NODE_DISTANCE: 225,
|
|
SPRING_LENGTH: 100,
|
|
LEVEL_SEPARATION: 150,
|
|
NODE_SPACING: 350,
|
|
TREE_SPACING: 250,
|
|
},
|
|
DRAWER: {
|
|
WIDTH: 400,
|
|
},
|
|
};
|
|
|
|
function loadVisCDN() {
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement("script");
|
|
script.src = "https://unpkg.com/vis-network@9.1.2/dist/vis-network.min.js";
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function drawRoundedRect(ctx, x, y, width, height, radius) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + radius, y);
|
|
ctx.lineTo(x + width - radius, y);
|
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
ctx.lineTo(x + width, y + height - radius);
|
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
ctx.lineTo(x + radius, y + height);
|
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
ctx.lineTo(x, y + radius);
|
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
ctx.closePath();
|
|
}
|
|
|
|
function highlightPython(code) {
|
|
return Prism.highlight(code, Prism.languages.python, "python");
|
|
}
|
|
|
|
class NodeRenderer {
|
|
constructor(nodes, networkManager) {
|
|
this.nodes = nodes;
|
|
this.networkManager = networkManager;
|
|
this.nodeScales = new Map();
|
|
this.scaleAnimations = new Map();
|
|
this.hoverGlowIntensities = new Map();
|
|
this.glowAnimations = new Map();
|
|
this.colorCache = new Map();
|
|
this.tempCanvas = document.createElement('canvas');
|
|
this.tempCanvas.width = 1;
|
|
this.tempCanvas.height = 1;
|
|
this.tempCtx = this.tempCanvas.getContext('2d');
|
|
}
|
|
|
|
render({ ctx, id, x, y }) {
|
|
const node = this.nodes.get(id);
|
|
if (!node?.nodeStyle) return {};
|
|
|
|
const scale = this.getNodeScale(id);
|
|
const isActiveDrawer = this.networkManager.drawerManager?.activeNodeId === id;
|
|
const isHovered = this.networkManager.hoveredNodeId === id && !isActiveDrawer;
|
|
const nodeStyle = node.nodeStyle;
|
|
|
|
// Manage hover glow intensity animation
|
|
const glowIntensity = this.getHoverGlowIntensity(id, isHovered);
|
|
|
|
ctx.font = `500 ${CONSTANTS.NODE.TEXT_SIZE * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`;
|
|
const textMetrics = ctx.measureText(nodeStyle.name);
|
|
const textWidth = textMetrics.width;
|
|
const textHeight = CONSTANTS.NODE.TEXT_SIZE * scale;
|
|
const textPadding = CONSTANTS.NODE.TEXT_PADDING * scale;
|
|
|
|
const width = textWidth + textPadding * 5;
|
|
const height = textHeight + textPadding * 2.5;
|
|
|
|
return {
|
|
drawNode: () => {
|
|
ctx.save();
|
|
const opacity = node.opacity !== undefined ? node.opacity : 1.0;
|
|
this.applyShadow(ctx, node, glowIntensity, opacity);
|
|
ctx.globalAlpha = opacity;
|
|
this.drawNodeShape(ctx, x, y, width, height, scale, nodeStyle, opacity, node);
|
|
this.drawNodeText(ctx, x, y, scale, nodeStyle, opacity, node);
|
|
ctx.restore();
|
|
},
|
|
nodeDimensions: { width, height },
|
|
};
|
|
}
|
|
|
|
getNodeScale(id) {
|
|
const isActiveDrawer = this.networkManager.drawerManager?.activeNodeId === id;
|
|
|
|
let targetScale = 1.0;
|
|
if (isActiveDrawer) {
|
|
targetScale = CONSTANTS.NODE.SELECTED_SCALE;
|
|
} else if (this.networkManager.pressedNodeId === id) {
|
|
targetScale = CONSTANTS.NODE.PRESSED_SCALE;
|
|
} else if (this.networkManager.hoveredNodeId === id) {
|
|
targetScale = CONSTANTS.NODE.HOVER_SCALE;
|
|
}
|
|
|
|
const currentScale = this.nodeScales.get(id) ?? 1.0;
|
|
const runningAnimation = this.scaleAnimations.get(id);
|
|
const animationTarget = runningAnimation?.targetScale;
|
|
|
|
if (Math.abs(targetScale - currentScale) > 0.001) {
|
|
if (runningAnimation && animationTarget !== targetScale) {
|
|
cancelAnimationFrame(runningAnimation.frameId);
|
|
this.scaleAnimations.delete(id);
|
|
}
|
|
|
|
if (!this.scaleAnimations.has(id)) {
|
|
this.animateScale(id, currentScale, targetScale);
|
|
}
|
|
}
|
|
|
|
return currentScale;
|
|
}
|
|
|
|
animateScale(id, startScale, targetScale) {
|
|
const startTime = performance.now();
|
|
const duration = 150;
|
|
|
|
const animate = () => {
|
|
const elapsed = performance.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
|
|
const currentScale = startScale + (targetScale - startScale) * eased;
|
|
this.nodeScales.set(id, currentScale);
|
|
|
|
if (progress < 1) {
|
|
const frameId = requestAnimationFrame(animate);
|
|
this.scaleAnimations.set(id, { frameId, targetScale });
|
|
} else {
|
|
this.scaleAnimations.delete(id);
|
|
this.nodeScales.set(id, targetScale);
|
|
}
|
|
|
|
this.networkManager.network?.redraw();
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
getHoverGlowIntensity(id, isHovered) {
|
|
const targetIntensity = isHovered ? 1.0 : 0.0;
|
|
const currentIntensity = this.hoverGlowIntensities.get(id) ?? 0.0;
|
|
const runningAnimation = this.glowAnimations.get(id);
|
|
const animationTarget = runningAnimation?.targetIntensity;
|
|
|
|
if (Math.abs(targetIntensity - currentIntensity) > 0.001) {
|
|
if (runningAnimation && animationTarget !== targetIntensity) {
|
|
cancelAnimationFrame(runningAnimation.frameId);
|
|
this.glowAnimations.delete(id);
|
|
}
|
|
|
|
if (!this.glowAnimations.has(id)) {
|
|
this.animateGlowIntensity(id, currentIntensity, targetIntensity);
|
|
}
|
|
}
|
|
|
|
return currentIntensity;
|
|
}
|
|
|
|
animateGlowIntensity(id, startIntensity, targetIntensity) {
|
|
const startTime = performance.now();
|
|
const duration = 200;
|
|
|
|
const animate = () => {
|
|
const elapsed = performance.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
|
|
const currentIntensity = startIntensity + (targetIntensity - startIntensity) * eased;
|
|
this.hoverGlowIntensities.set(id, currentIntensity);
|
|
|
|
if (progress < 1) {
|
|
const frameId = requestAnimationFrame(animate);
|
|
this.glowAnimations.set(id, { frameId, targetIntensity });
|
|
} else {
|
|
this.glowAnimations.delete(id);
|
|
this.hoverGlowIntensities.set(id, targetIntensity);
|
|
}
|
|
|
|
this.networkManager.network?.redraw();
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
applyShadow(ctx, node, glowIntensity = 0, nodeOpacity = 1.0) {
|
|
if (glowIntensity > 0.001) {
|
|
// Save current alpha and apply glow at full opacity
|
|
const currentAlpha = ctx.globalAlpha;
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
|
|
// Use CrewAI orange for hover glow in both themes
|
|
const glowR = 255;
|
|
const glowG = 90;
|
|
const glowB = 80;
|
|
const blurRadius = isDarkMode ? 20 : 35;
|
|
|
|
// Scale glow intensity proportionally based on node opacity
|
|
// When node is inactive (opacity < 1.0), reduce glow intensity accordingly
|
|
const scaledGlowIntensity = glowIntensity * nodeOpacity;
|
|
|
|
const glowColor = `rgba(${glowR}, ${glowG}, ${glowB}, ${scaledGlowIntensity})`;
|
|
|
|
ctx.shadowColor = glowColor;
|
|
ctx.shadowBlur = blurRadius * scaledGlowIntensity;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 0;
|
|
|
|
// Restore the original alpha
|
|
ctx.globalAlpha = currentAlpha;
|
|
return;
|
|
}
|
|
|
|
if (node.shadow?.enabled) {
|
|
ctx.shadowColor = node.shadow.color || "rgba(0,0,0,0.1)";
|
|
ctx.shadowBlur = node.shadow.size || 8;
|
|
ctx.shadowOffsetX = node.shadow.x || 0;
|
|
ctx.shadowOffsetY = node.shadow.y || 0;
|
|
return;
|
|
}
|
|
|
|
ctx.shadowColor = "transparent";
|
|
ctx.shadowBlur = 0;
|
|
ctx.shadowOffsetX = 0;
|
|
ctx.shadowOffsetY = 0;
|
|
}
|
|
|
|
resolveCSSVariable(color) {
|
|
if (color?.startsWith('var(')) {
|
|
const varName = color.match(/var\((--[^)]+)\)/)?.[1];
|
|
if (varName) {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
|
}
|
|
}
|
|
return color;
|
|
}
|
|
|
|
|
|
parseColor(color) {
|
|
const cacheKey = `parse_${color}`;
|
|
if (this.colorCache.has(cacheKey)) {
|
|
return this.colorCache.get(cacheKey);
|
|
}
|
|
|
|
this.tempCtx.fillStyle = color;
|
|
this.tempCtx.fillRect(0, 0, 1, 1);
|
|
const [r, g, b] = this.tempCtx.getImageData(0, 0, 1, 1).data;
|
|
|
|
const result = { r, g, b };
|
|
this.colorCache.set(cacheKey, result);
|
|
return result;
|
|
}
|
|
|
|
darkenColor(color, opacity) {
|
|
if (opacity >= 0.9) return color;
|
|
|
|
const { r, g, b } = this.parseColor(color);
|
|
|
|
const t = (opacity - 0.85) / (1.0 - 0.85);
|
|
const normalizedT = Math.max(0, Math.min(1, t));
|
|
|
|
const minBrightness = 0.4;
|
|
const brightness = minBrightness + (1.0 - minBrightness) * normalizedT;
|
|
|
|
const newR = Math.floor(r * brightness);
|
|
const newG = Math.floor(g * brightness);
|
|
const newB = Math.floor(b * brightness);
|
|
|
|
return `rgb(${newR}, ${newG}, ${newB})`;
|
|
}
|
|
|
|
desaturateColor(color, opacity) {
|
|
if (opacity >= 0.9) return color;
|
|
|
|
const { r, g, b } = this.parseColor(color);
|
|
|
|
// Convert to HSL to adjust saturation and lightness
|
|
const max = Math.max(r, g, b) / 255;
|
|
const min = Math.min(r, g, b) / 255;
|
|
const l = (max + min) / 2;
|
|
let h = 0, s = 0;
|
|
|
|
if (max !== min) {
|
|
const d = max - min;
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
|
|
if (max === r / 255) {
|
|
h = ((g / 255 - b / 255) / d + (g < b ? 6 : 0)) / 6;
|
|
} else if (max === g / 255) {
|
|
h = ((b / 255 - r / 255) / d + 2) / 6;
|
|
} else {
|
|
h = ((r / 255 - g / 255) / d + 4) / 6;
|
|
}
|
|
}
|
|
|
|
// Reduce saturation and lightness by 40%
|
|
s = s * 0.6;
|
|
const newL = l * 0.6;
|
|
|
|
// Convert back to RGB
|
|
const hue2rgb = (p, q, t) => {
|
|
if (t < 0) t += 1;
|
|
if (t > 1) t -= 1;
|
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
if (t < 1/2) return q;
|
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
return p;
|
|
};
|
|
|
|
let newR, newG, newB;
|
|
if (s === 0) {
|
|
newR = newG = newB = Math.floor(newL * 255);
|
|
} else {
|
|
const q = newL < 0.5 ? newL * (1 + s) : newL + s - newL * s;
|
|
const p = 2 * newL - q;
|
|
newR = Math.floor(hue2rgb(p, q, h + 1/3) * 255);
|
|
newG = Math.floor(hue2rgb(p, q, h) * 255);
|
|
newB = Math.floor(hue2rgb(p, q, h - 1/3) * 255);
|
|
}
|
|
|
|
return `rgb(${newR}, ${newG}, ${newB})`;
|
|
}
|
|
|
|
drawNodeShape(ctx, x, y, width, height, scale, nodeStyle, opacity = 1.0, node = null) {
|
|
const radius = CONSTANTS.NODE.BORDER_RADIUS * scale;
|
|
const rectX = x - width / 2;
|
|
const rectY = y - height / 2;
|
|
|
|
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
const nodeData = '{{ nodeData }}';
|
|
const metadata = node ? nodeData[node.id] : null;
|
|
const isStartNode = metadata && metadata.type === 'start';
|
|
|
|
let nodeColor;
|
|
|
|
if (isDarkMode || isStartNode) {
|
|
// In dark mode or for start nodes, use the theme color
|
|
nodeColor = this.resolveCSSVariable(nodeStyle.bgColor);
|
|
} else {
|
|
// In light mode for non-start nodes, use white
|
|
nodeColor = 'rgb(255, 255, 255)';
|
|
}
|
|
|
|
// Parse the base color to get RGB values
|
|
let { r, g, b } = this.parseColor(nodeColor);
|
|
|
|
// For inactive nodes, check if node is in highlighted list
|
|
// If drawer is open and node is not highlighted, it's inactive
|
|
const isDrawerOpen = this.networkManager.drawerManager?.activeNodeId !== null;
|
|
const isHighlighted = this.networkManager.triggeredByHighlighter?.highlightedNodes?.includes(node?.id);
|
|
const isActiveNode = this.networkManager.drawerManager?.activeNodeId === node?.id;
|
|
const hasHighlightedNodes = this.networkManager.triggeredByHighlighter?.highlightedNodes?.length > 0;
|
|
|
|
// Non-prominent nodes: drawer is open, has highlighted nodes, but this node is not highlighted or active
|
|
const isNonProminent = isDrawerOpen && hasHighlightedNodes && !isHighlighted && !isActiveNode;
|
|
|
|
// Inactive nodes: drawer is open but no highlighted nodes, and this node is not active
|
|
const isInactive = isDrawerOpen && !hasHighlightedNodes && !isActiveNode;
|
|
|
|
if (isNonProminent || isInactive) {
|
|
// Make non-prominent and inactive nodes a darker version of the normal active color
|
|
const darkenFactor = 0.4; // Keep 40% of original color (darken by 60%)
|
|
r = Math.round(r * darkenFactor);
|
|
g = Math.round(g * darkenFactor);
|
|
b = Math.round(b * darkenFactor);
|
|
}
|
|
|
|
// Draw base shape with frosted glass effect
|
|
ctx.beginPath();
|
|
drawRoundedRect(ctx, rectX, rectY, width, height, radius);
|
|
// Use full opacity for all nodes
|
|
const glassOpacity = 1.0;
|
|
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${glassOpacity})`;
|
|
ctx.fill();
|
|
|
|
// Calculate text label area to exclude from frosted overlay
|
|
const textPadding = CONSTANTS.NODE.TEXT_PADDING * scale;
|
|
const textBgRadius = CONSTANTS.NODE.TEXT_BG_RADIUS * scale;
|
|
|
|
ctx.font = `500 ${CONSTANTS.NODE.TEXT_SIZE * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`;
|
|
const textMetrics = ctx.measureText(nodeStyle.name);
|
|
const textWidth = textMetrics.width;
|
|
const textHeight = CONSTANTS.NODE.TEXT_SIZE * scale;
|
|
const textBgWidth = textWidth + textPadding * 2;
|
|
const textBgHeight = textHeight + textPadding * 0.75;
|
|
const textBgX = x - textBgWidth / 2;
|
|
const textBgY = y - textBgHeight / 2;
|
|
|
|
// Add frosted overlay (clipped to node shape, excluding text area)
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
drawRoundedRect(ctx, rectX, rectY, width, height, radius);
|
|
ctx.clip();
|
|
|
|
// Cut out the text label area from the frosted overlay
|
|
ctx.beginPath();
|
|
drawRoundedRect(ctx, rectX, rectY, width, height, radius);
|
|
drawRoundedRect(ctx, textBgX, textBgY, textBgWidth, textBgHeight, textBgRadius);
|
|
ctx.clip('evenodd');
|
|
|
|
// For inactive nodes, use stronger absolute frost values
|
|
// For active nodes, scale frost with opacity
|
|
let frostTop, frostMid, frostBottom;
|
|
if (isInactive) {
|
|
// Inactive nodes get stronger, more consistent frost
|
|
frostTop = 0.45;
|
|
frostMid = 0.35;
|
|
frostBottom = 0.25;
|
|
} else {
|
|
// Active nodes get opacity-scaled frost
|
|
frostTop = opacity * 0.3;
|
|
frostMid = opacity * 0.2;
|
|
frostBottom = opacity * 0.15;
|
|
}
|
|
|
|
// Stronger white overlay for frosted appearance
|
|
const frostOverlay = ctx.createLinearGradient(rectX, rectY, rectX, rectY + height);
|
|
frostOverlay.addColorStop(0, `rgba(255, 255, 255, ${frostTop})`);
|
|
frostOverlay.addColorStop(0.5, `rgba(255, 255, 255, ${frostMid})`);
|
|
frostOverlay.addColorStop(1, `rgba(255, 255, 255, ${frostBottom})`);
|
|
|
|
ctx.fillStyle = frostOverlay;
|
|
ctx.fillRect(rectX, rectY, width, height);
|
|
ctx.restore();
|
|
|
|
ctx.shadowColor = "transparent";
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Draw border at full opacity (desaturated for inactive nodes)
|
|
// Reset globalAlpha to 1.0 so the border is always fully visible
|
|
ctx.save();
|
|
ctx.globalAlpha = 1.0;
|
|
ctx.beginPath();
|
|
drawRoundedRect(ctx, rectX, rectY, width, height, radius);
|
|
const borderColor = this.resolveCSSVariable(nodeStyle.borderColor);
|
|
let finalBorderColor = this.desaturateColor(borderColor, opacity);
|
|
|
|
// Darken border color for non-prominent and inactive nodes
|
|
if (isNonProminent || isInactive) {
|
|
const borderRGB = this.parseColor(finalBorderColor);
|
|
const darkenFactor = 0.4;
|
|
const darkenedR = Math.round(borderRGB.r * darkenFactor);
|
|
const darkenedG = Math.round(borderRGB.g * darkenFactor);
|
|
const darkenedB = Math.round(borderRGB.b * darkenFactor);
|
|
finalBorderColor = `rgb(${darkenedR}, ${darkenedG}, ${darkenedB})`;
|
|
}
|
|
|
|
ctx.strokeStyle = finalBorderColor;
|
|
ctx.lineWidth = nodeStyle.borderWidth * scale;
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
drawNodeText(ctx, x, y, scale, nodeStyle, opacity = 1.0, node = null) {
|
|
ctx.font = `500 ${CONSTANTS.NODE.TEXT_SIZE * scale}px 'JetBrains Mono', 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
|
|
const textMetrics = ctx.measureText(nodeStyle.name);
|
|
const textWidth = textMetrics.width;
|
|
const textHeight = CONSTANTS.NODE.TEXT_SIZE * scale;
|
|
const textPadding = CONSTANTS.NODE.TEXT_PADDING * scale;
|
|
const textBgRadius = CONSTANTS.NODE.TEXT_BG_RADIUS * scale;
|
|
|
|
const textBgWidth = textWidth + textPadding * 2;
|
|
const textBgHeight = textHeight + textPadding * 0.75;
|
|
const textBgX = x - textBgWidth / 2;
|
|
const textBgY = y - textBgHeight / 2;
|
|
|
|
drawRoundedRect(
|
|
ctx,
|
|
textBgX,
|
|
textBgY,
|
|
textBgWidth,
|
|
textBgHeight,
|
|
textBgRadius,
|
|
);
|
|
|
|
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
const nodeData = '{{ nodeData }}';
|
|
const metadata = node ? nodeData[node.id] : null;
|
|
const isStartNode = metadata && metadata.type === 'start';
|
|
|
|
// Check if this is an inactive or non-prominent node using the same logic as drawNodeShape
|
|
const isDrawerOpen = this.networkManager.drawerManager?.activeNodeId !== null;
|
|
const isHighlighted = this.networkManager.triggeredByHighlighter?.highlightedNodes?.includes(node?.id);
|
|
const isActiveNode = this.networkManager.drawerManager?.activeNodeId === node?.id;
|
|
const hasHighlightedNodes = this.networkManager.triggeredByHighlighter?.highlightedNodes?.length > 0;
|
|
|
|
const isNonProminent = isDrawerOpen && hasHighlightedNodes && !isHighlighted && !isActiveNode;
|
|
const isInactive = isDrawerOpen && !hasHighlightedNodes && !isActiveNode;
|
|
|
|
// Get the base node color to darken it for inactive nodes
|
|
let nodeColor;
|
|
if (isDarkMode || isStartNode) {
|
|
nodeColor = this.resolveCSSVariable(nodeStyle.bgColor);
|
|
} else {
|
|
nodeColor = 'rgb(255, 255, 255)';
|
|
}
|
|
const { r, g, b } = this.parseColor(nodeColor);
|
|
|
|
let labelBgR = 255, labelBgG = 255, labelBgB = 255;
|
|
let labelBgOpacity = 0.2 * opacity;
|
|
|
|
if (isNonProminent || isInactive) {
|
|
// Darken the base node color for non-prominent and inactive label backgrounds
|
|
const darkenFactor = 0.4;
|
|
labelBgR = Math.round(r * darkenFactor);
|
|
labelBgG = Math.round(g * darkenFactor);
|
|
labelBgB = Math.round(b * darkenFactor);
|
|
labelBgOpacity = 0.5;
|
|
} else if (!isDarkMode && !isStartNode) {
|
|
// In light mode for non-start nodes, use gray for active node labels
|
|
labelBgR = labelBgG = labelBgB = 128;
|
|
labelBgOpacity = 0.25;
|
|
}
|
|
|
|
ctx.fillStyle = `rgba(${labelBgR}, ${labelBgG}, ${labelBgB}, ${labelBgOpacity})`;
|
|
ctx.fill();
|
|
|
|
// For start nodes or dark mode, use theme color; in light mode, use dark text
|
|
let fontColor;
|
|
if (isDarkMode || isStartNode) {
|
|
fontColor = this.resolveCSSVariable(nodeStyle.fontColor);
|
|
} else {
|
|
fontColor = 'rgb(30, 30, 30)';
|
|
}
|
|
|
|
// Darken font color for non-prominent and inactive nodes
|
|
if (isNonProminent || isInactive) {
|
|
const fontRGB = this.parseColor(fontColor);
|
|
const darkenFactor = 0.4;
|
|
const darkenedR = Math.round(fontRGB.r * darkenFactor);
|
|
const darkenedG = Math.round(fontRGB.g * darkenFactor);
|
|
const darkenedB = Math.round(fontRGB.b * darkenFactor);
|
|
fontColor = `rgb(${darkenedR}, ${darkenedG}, ${darkenedB})`;
|
|
}
|
|
|
|
ctx.fillStyle = fontColor;
|
|
ctx.fillText(nodeStyle.name, x, y);
|
|
}
|
|
}
|
|
|
|
class AnimationManager {
|
|
constructor() {
|
|
this.animations = new Map();
|
|
}
|
|
|
|
animateEdgeWidth(
|
|
edges,
|
|
edgeId,
|
|
targetWidth,
|
|
duration = CONSTANTS.EDGE.ANIMATION_DURATION,
|
|
) {
|
|
this.cancel(edgeId);
|
|
|
|
const edge = edges.get(edgeId);
|
|
if (!edge) return;
|
|
|
|
const startWidth = edge.width || CONSTANTS.EDGE.DEFAULT_WIDTH;
|
|
const startTime = performance.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = performance.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
const currentWidth = startWidth + (targetWidth - startWidth) * eased;
|
|
|
|
edges.update({ id: edgeId, width: currentWidth });
|
|
|
|
if (progress < 1) {
|
|
const frameId = requestAnimationFrame(animate);
|
|
this.animations.set(edgeId, frameId);
|
|
} else {
|
|
this.animations.delete(edgeId);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
cancel(id) {
|
|
if (this.animations.has(id)) {
|
|
cancelAnimationFrame(this.animations.get(id));
|
|
this.animations.delete(id);
|
|
}
|
|
}
|
|
|
|
cancelAll() {
|
|
this.animations.forEach((frameId) => cancelAnimationFrame(frameId));
|
|
this.animations.clear();
|
|
}
|
|
}
|
|
|
|
class TriggeredByHighlighter {
|
|
constructor(network, nodes, edges, highlightCanvas) {
|
|
this.network = network;
|
|
this.nodes = nodes;
|
|
this.edges = edges;
|
|
this.canvas = highlightCanvas;
|
|
this.ctx = highlightCanvas.getContext("2d");
|
|
|
|
this.highlightedNodes = [];
|
|
this.highlightedEdges = [];
|
|
this.activeDrawerNodeId = null;
|
|
this.activeDrawerEdges = [];
|
|
|
|
this.setupCanvas();
|
|
}
|
|
|
|
setupCanvas() {
|
|
this.resizeCanvas();
|
|
this.canvas.classList.remove("visible");
|
|
window.addEventListener("resize", () => this.resizeCanvas());
|
|
}
|
|
|
|
resizeCanvas() {
|
|
this.canvas.width = window.innerWidth;
|
|
this.canvas.height = window.innerHeight;
|
|
}
|
|
|
|
setActiveDrawer(nodeId, edges) {
|
|
this.activeDrawerNodeId = nodeId;
|
|
this.activeDrawerEdges = edges;
|
|
}
|
|
|
|
highlightTriggeredByGroup(triggerNodeIds) {
|
|
this.clear();
|
|
|
|
if (!this.activeDrawerNodeId || !triggerNodeIds || triggerNodeIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const allEdges = this.edges.get();
|
|
const pathNodes = new Set([this.activeDrawerNodeId]);
|
|
const connectingEdges = [];
|
|
const nodeData = '{{ nodeData }}';
|
|
|
|
triggerNodeIds.forEach(triggerNodeId => {
|
|
const directEdges = allEdges.filter(
|
|
(edge) => edge.from === triggerNodeId && edge.to === this.activeDrawerNodeId
|
|
);
|
|
|
|
if (directEdges.length > 0) {
|
|
directEdges.forEach(edge => {
|
|
connectingEdges.push(edge);
|
|
pathNodes.add(edge.from);
|
|
pathNodes.add(edge.to);
|
|
});
|
|
} else {
|
|
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
|
|
if (nodeInfo.router_paths && nodeInfo.router_paths.includes(triggerNodeId)) {
|
|
const routerNode = nodeName;
|
|
|
|
const routerEdges = allEdges.filter(
|
|
(edge) => edge.from === routerNode && edge.dashes
|
|
);
|
|
let foundEdge = false;
|
|
|
|
for (const routerEdge of routerEdges) {
|
|
if (routerEdge.label === triggerNodeId) {
|
|
connectingEdges.push(routerEdge);
|
|
pathNodes.add(routerNode);
|
|
pathNodes.add(routerEdge.to);
|
|
|
|
if (routerEdge.to !== this.activeDrawerNodeId) {
|
|
const pathToActive = allEdges.filter(
|
|
(edge) => edge.from === routerEdge.to && edge.to === this.activeDrawerNodeId
|
|
);
|
|
|
|
if (pathToActive.length > 0) {
|
|
connectingEdges.push(...pathToActive);
|
|
pathNodes.add(this.activeDrawerNodeId);
|
|
}
|
|
}
|
|
|
|
foundEdge = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!foundEdge) {
|
|
for (const routerEdge of routerEdges) {
|
|
if (routerEdge.to === triggerNodeId) {
|
|
connectingEdges.push(routerEdge);
|
|
pathNodes.add(routerNode);
|
|
pathNodes.add(routerEdge.to);
|
|
|
|
const pathToActive = allEdges.filter(
|
|
(edge) => edge.from === triggerNodeId && edge.to === this.activeDrawerNodeId
|
|
);
|
|
|
|
if (pathToActive.length > 0) {
|
|
connectingEdges.push(...pathToActive);
|
|
pathNodes.add(this.activeDrawerNodeId);
|
|
}
|
|
|
|
foundEdge = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!foundEdge) {
|
|
const directRouterEdge = routerEdges.find(
|
|
(edge) => edge.to === this.activeDrawerNodeId
|
|
);
|
|
|
|
if (directRouterEdge) {
|
|
connectingEdges.push(directRouterEdge);
|
|
pathNodes.add(routerNode);
|
|
pathNodes.add(this.activeDrawerNodeId);
|
|
foundEdge = true;
|
|
}
|
|
}
|
|
|
|
if (foundEdge) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (connectingEdges.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.highlightedNodes = Array.from(pathNodes);
|
|
this.highlightedEdges = connectingEdges.map((e) => e.id);
|
|
|
|
this.animateNodeOpacity();
|
|
this.animateEdgeStyles();
|
|
}
|
|
|
|
highlightAllRouterPaths() {
|
|
this.clear();
|
|
|
|
if (!this.activeDrawerNodeId) {
|
|
return;
|
|
}
|
|
|
|
const allEdges = this.edges.get();
|
|
const nodeData = '{{ nodeData }}';
|
|
const activeMetadata = nodeData[this.activeDrawerNodeId];
|
|
|
|
const outgoingRouterEdges = allEdges.filter(
|
|
(edge) => edge.from === this.activeDrawerNodeId && edge.dashes
|
|
);
|
|
|
|
let routerEdges = [];
|
|
const pathNodes = new Set();
|
|
|
|
if (outgoingRouterEdges.length > 0) {
|
|
routerEdges = outgoingRouterEdges;
|
|
pathNodes.add(this.activeDrawerNodeId);
|
|
routerEdges.forEach(edge => {
|
|
pathNodes.add(edge.to);
|
|
});
|
|
} else if (activeMetadata && activeMetadata.router_paths && activeMetadata.router_paths.length > 0) {
|
|
activeMetadata.router_paths.forEach(pathName => {
|
|
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
|
|
if (nodeInfo.router_paths && nodeInfo.router_paths.includes(pathName)) {
|
|
const edgeFromRouter = allEdges.filter(
|
|
(edge) => edge.from === nodeName && edge.to === this.activeDrawerNodeId && edge.dashes
|
|
);
|
|
|
|
if (edgeFromRouter.length > 0) {
|
|
routerEdges.push(...edgeFromRouter);
|
|
pathNodes.add(nodeName);
|
|
pathNodes.add(this.activeDrawerNodeId);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (routerEdges.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.highlightedNodes = Array.from(pathNodes);
|
|
this.highlightedEdges = routerEdges.map((e) => e.id);
|
|
|
|
this.animateNodeOpacity();
|
|
this.animateEdgeStyles();
|
|
}
|
|
|
|
highlightTriggeredBy(triggerNodeId) {
|
|
this.clear();
|
|
|
|
if (this.activeDrawerEdges && this.activeDrawerEdges.length > 0) {
|
|
// Animate the activeDrawerEdges back to default
|
|
this.resetEdgesToDefault(this.activeDrawerEdges);
|
|
this.activeDrawerEdges = [];
|
|
}
|
|
|
|
if (!this.activeDrawerNodeId || !triggerNodeId) {
|
|
return;
|
|
}
|
|
|
|
const allEdges = this.edges.get();
|
|
let connectingEdges = [];
|
|
let actualTriggerNodeId = triggerNodeId;
|
|
|
|
connectingEdges = allEdges.filter(
|
|
(edge) =>
|
|
edge.from === triggerNodeId && edge.to === this.activeDrawerNodeId,
|
|
);
|
|
|
|
if (connectingEdges.length === 0) {
|
|
const incomingRouterEdges = allEdges.filter(
|
|
(edge) => edge.to === this.activeDrawerNodeId && edge.dashes,
|
|
);
|
|
|
|
if (incomingRouterEdges.length > 0) {
|
|
incomingRouterEdges.forEach((edge) => {
|
|
connectingEdges.push(edge);
|
|
actualTriggerNodeId = edge.from;
|
|
});
|
|
}
|
|
}
|
|
|
|
if (connectingEdges.length === 0) {
|
|
const outgoingRouterEdges = allEdges.filter(
|
|
(edge) => edge.from === this.activeDrawerNodeId && edge.dashes,
|
|
);
|
|
|
|
if (outgoingRouterEdges.length > 0) {
|
|
const nodeData = '{{ nodeData }}';
|
|
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
|
|
if (
|
|
nodeInfo.trigger_methods &&
|
|
nodeInfo.trigger_methods.includes(triggerNodeId)
|
|
) {
|
|
const edgeToTarget = outgoingRouterEdges.find(
|
|
(e) => e.to === nodeName,
|
|
);
|
|
if (edgeToTarget) {
|
|
connectingEdges.push(edgeToTarget);
|
|
actualTriggerNodeId = nodeName;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (connectingEdges.length === 0) {
|
|
const nodeData = '{{ nodeData }}';
|
|
|
|
const activeMetadata = nodeData[this.activeDrawerNodeId];
|
|
if (
|
|
activeMetadata &&
|
|
activeMetadata.trigger_methods &&
|
|
activeMetadata.trigger_methods.includes(triggerNodeId)
|
|
) {
|
|
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
|
|
if (
|
|
nodeInfo.router_paths &&
|
|
nodeInfo.router_paths.includes(triggerNodeId)
|
|
) {
|
|
const routerNode = nodeName;
|
|
|
|
const routerEdges = allEdges.filter(
|
|
(edge) => edge.from === routerNode && edge.dashes,
|
|
);
|
|
|
|
for (const routerEdge of routerEdges) {
|
|
const intermediateNode = routerEdge.to;
|
|
|
|
if (intermediateNode === this.activeDrawerNodeId) {
|
|
connectingEdges = [routerEdge];
|
|
actualTriggerNodeId = routerNode;
|
|
break;
|
|
}
|
|
|
|
const pathToActive = allEdges.filter(
|
|
(edge) =>
|
|
edge.from === intermediateNode &&
|
|
edge.to === this.activeDrawerNodeId,
|
|
);
|
|
|
|
if (pathToActive.length > 0) {
|
|
connectingEdges = [routerEdge, ...pathToActive];
|
|
actualTriggerNodeId = routerNode;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (connectingEdges.length > 0) break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (connectingEdges.length === 0) {
|
|
const edgesWithLabel = allEdges.filter(
|
|
(edge) =>
|
|
edge.dashes &&
|
|
edge.label === triggerNodeId &&
|
|
edge.to === this.activeDrawerNodeId,
|
|
);
|
|
|
|
if (edgesWithLabel.length > 0) {
|
|
connectingEdges = edgesWithLabel;
|
|
const firstEdge = edgesWithLabel[0];
|
|
actualTriggerNodeId = firstEdge.from;
|
|
}
|
|
}
|
|
|
|
if (connectingEdges.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const pathNodes = new Set([actualTriggerNodeId, this.activeDrawerNodeId]);
|
|
connectingEdges.forEach((edge) => {
|
|
pathNodes.add(edge.from);
|
|
pathNodes.add(edge.to);
|
|
});
|
|
|
|
this.highlightedNodes = Array.from(pathNodes);
|
|
this.highlightedEdges = connectingEdges.map((e) => e.id);
|
|
|
|
this.animateNodeOpacity();
|
|
this.animateEdgeStyles();
|
|
}
|
|
|
|
animateNodeOpacity() {
|
|
const allNodesList = this.nodes.get();
|
|
const nodeAnimDuration = CONSTANTS.ANIMATION.DURATION;
|
|
const nodeAnimStart = performance.now();
|
|
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
|
|
const animate = () => {
|
|
const elapsed = performance.now() - nodeAnimStart;
|
|
const progress = Math.min(elapsed / nodeAnimDuration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
|
|
allNodesList.forEach((node) => {
|
|
const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0;
|
|
// Keep inactive nodes at full opacity
|
|
const inactiveOpacity = 1.0;
|
|
const targetOpacity = this.highlightedNodes.includes(node.id)
|
|
? 1.0
|
|
: inactiveOpacity;
|
|
const newOpacity =
|
|
currentOpacity + (targetOpacity - currentOpacity) * eased;
|
|
|
|
this.nodes.update({
|
|
id: node.id,
|
|
opacity: newOpacity,
|
|
});
|
|
});
|
|
|
|
this.network.redraw();
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
animateEdgeStyles() {
|
|
const edgeIds = this.edges.getIds();
|
|
const edgeAnimDuration = CONSTANTS.ANIMATION.DURATION;
|
|
const edgeAnimStart = performance.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = performance.now() - edgeAnimStart;
|
|
const progress = Math.min(elapsed / edgeAnimDuration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
|
|
edgeIds.forEach((edgeId) => {
|
|
const edge = this.edges.get(edgeId);
|
|
if (!edge) return;
|
|
|
|
if (this.highlightedEdges.includes(edge.id)) {
|
|
const currentWidth = edge.width || CONSTANTS.EDGE.DEFAULT_WIDTH;
|
|
const targetWidth = CONSTANTS.EDGE.HIGHLIGHTED_WIDTH;
|
|
const newWidth = currentWidth + (targetWidth - currentWidth) * eased;
|
|
|
|
const currentShadowSize =
|
|
edge.shadow?.size || CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE;
|
|
const targetShadowSize = CONSTANTS.EDGE.HIGHLIGHTED_SHADOW_SIZE;
|
|
const newShadowSize =
|
|
currentShadowSize + (targetShadowSize - currentShadowSize) * eased;
|
|
|
|
const isAndOrRouter = edge.dashes || edge.label === "AND";
|
|
const highlightColor = isAndOrRouter
|
|
? "{{ CREWAI_ORANGE }}"
|
|
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
|
|
|
|
const updateData = {
|
|
id: edge.id,
|
|
hidden: false,
|
|
opacity: 1.0,
|
|
width: newWidth,
|
|
color: {
|
|
color: highlightColor,
|
|
highlight: highlightColor,
|
|
},
|
|
shadow: {
|
|
enabled: true,
|
|
color: highlightColor,
|
|
size: newShadowSize,
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
};
|
|
|
|
if (edge.dashes) {
|
|
const scale = Math.sqrt(newWidth / CONSTANTS.EDGE.DEFAULT_WIDTH);
|
|
updateData.dashes = [15 * scale, 10 * scale];
|
|
}
|
|
|
|
updateData.arrows = {
|
|
to: {
|
|
enabled: true,
|
|
scaleFactor: 0.8,
|
|
type: "triangle",
|
|
},
|
|
};
|
|
|
|
updateData.color = {
|
|
color: highlightColor,
|
|
highlight: highlightColor,
|
|
hover: highlightColor,
|
|
inherit: "to",
|
|
};
|
|
|
|
this.edges.update(updateData);
|
|
} else {
|
|
const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0;
|
|
// Keep inactive edges at full opacity
|
|
const targetOpacity = 1.0;
|
|
const newOpacity = currentOpacity + (targetOpacity - currentOpacity) * eased;
|
|
|
|
const currentWidth = edge.width !== undefined ? edge.width : CONSTANTS.EDGE.DEFAULT_WIDTH;
|
|
const targetWidth = 1.2;
|
|
const newWidth = currentWidth + (targetWidth - currentWidth) * eased;
|
|
|
|
// Keep the original edge color instead of turning gray
|
|
const isAndOrRouter = edge.dashes || edge.label === "AND";
|
|
const baseColor = isAndOrRouter
|
|
? "{{ CREWAI_ORANGE }}"
|
|
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
|
|
|
|
// Convert color to rgba with opacity for vis.js
|
|
let inactiveEdgeColor;
|
|
if (baseColor.startsWith('#')) {
|
|
// Convert hex to rgba
|
|
const hex = baseColor.replace('#', '');
|
|
const r = parseInt(hex.substr(0, 2), 16);
|
|
const g = parseInt(hex.substr(2, 2), 16);
|
|
const b = parseInt(hex.substr(4, 2), 16);
|
|
inactiveEdgeColor = `rgba(${r}, ${g}, ${b}, ${newOpacity})`;
|
|
} else if (baseColor.startsWith('rgb(')) {
|
|
inactiveEdgeColor = baseColor.replace('rgb(', `rgba(`).replace(')', `, ${newOpacity})`);
|
|
} else {
|
|
inactiveEdgeColor = baseColor;
|
|
}
|
|
|
|
this.edges.update({
|
|
id: edge.id,
|
|
hidden: false,
|
|
width: newWidth,
|
|
color: {
|
|
color: inactiveEdgeColor,
|
|
highlight: inactiveEdgeColor,
|
|
hover: inactiveEdgeColor,
|
|
},
|
|
shadow: {
|
|
enabled: false,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
resetEdgesToDefault(edgeIds = null, excludeEdges = []) {
|
|
const targetEdgeIds = edgeIds || this.edges.getIds();
|
|
const edgeAnimDuration = CONSTANTS.ANIMATION.DURATION;
|
|
const edgeAnimStart = performance.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = performance.now() - edgeAnimStart;
|
|
const progress = Math.min(elapsed / edgeAnimDuration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
|
|
targetEdgeIds.forEach((edgeId) => {
|
|
if (excludeEdges.includes(edgeId)) {
|
|
return;
|
|
}
|
|
|
|
const edge = this.edges.get(edgeId);
|
|
if (!edge) return;
|
|
|
|
const defaultColor =
|
|
edge.dashes || edge.label === "AND"
|
|
? "{{ CREWAI_ORANGE }}"
|
|
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
|
|
const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0;
|
|
const currentWidth =
|
|
edge.width !== undefined ? edge.width : CONSTANTS.EDGE.DEFAULT_WIDTH;
|
|
const currentShadowSize =
|
|
edge.shadow && edge.shadow.size !== undefined
|
|
? edge.shadow.size
|
|
: CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE;
|
|
|
|
const targetOpacity = 1.0;
|
|
const targetWidth = CONSTANTS.EDGE.DEFAULT_WIDTH;
|
|
const targetShadowSize = CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE;
|
|
|
|
const newOpacity =
|
|
currentOpacity + (targetOpacity - currentOpacity) * eased;
|
|
const newWidth = currentWidth + (targetWidth - currentWidth) * eased;
|
|
const newShadowSize =
|
|
currentShadowSize + (targetShadowSize - currentShadowSize) * eased;
|
|
|
|
const updateData = {
|
|
id: edge.id,
|
|
hidden: false,
|
|
opacity: newOpacity,
|
|
width: newWidth,
|
|
color: {
|
|
color: defaultColor,
|
|
highlight: defaultColor,
|
|
hover: defaultColor,
|
|
inherit: false,
|
|
},
|
|
shadow: {
|
|
enabled: true,
|
|
color: "rgba(0,0,0,0.08)",
|
|
size: newShadowSize,
|
|
x: 1,
|
|
y: 1,
|
|
},
|
|
font: {
|
|
color: "transparent",
|
|
background: "transparent",
|
|
},
|
|
arrows: {
|
|
to: {
|
|
enabled: true,
|
|
scaleFactor: 0.8,
|
|
type: "triangle",
|
|
},
|
|
},
|
|
};
|
|
|
|
if (edge.dashes) {
|
|
const scale = Math.sqrt(newWidth / CONSTANTS.EDGE.DEFAULT_WIDTH);
|
|
updateData.dashes = [15 * scale, 10 * scale];
|
|
}
|
|
|
|
this.edges.update(updateData);
|
|
});
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
clear() {
|
|
const allNodesList = this.nodes.get();
|
|
const nodeRestoreAnimStart = performance.now();
|
|
const nodeRestoreAnimDuration = CONSTANTS.ANIMATION.DURATION;
|
|
|
|
const animate = () => {
|
|
const elapsed = performance.now() - nodeRestoreAnimStart;
|
|
const progress = Math.min(elapsed / nodeRestoreAnimDuration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
|
|
allNodesList.forEach((node) => {
|
|
const currentOpacity = node.opacity !== undefined ? node.opacity : 1.0;
|
|
const targetOpacity = 1.0;
|
|
const newOpacity =
|
|
currentOpacity + (targetOpacity - currentOpacity) * eased;
|
|
this.nodes.update({ id: node.id, opacity: newOpacity });
|
|
});
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
|
|
const edgeIds = this.edges.getIds();
|
|
const edgeRestoreAnimStart = performance.now();
|
|
const edgeRestoreAnimDuration = CONSTANTS.ANIMATION.DURATION;
|
|
|
|
const animateEdges = () => {
|
|
const elapsed = performance.now() - edgeRestoreAnimStart;
|
|
const progress = Math.min(elapsed / edgeRestoreAnimDuration, 1);
|
|
const eased = CONSTANTS.ANIMATION.EASE_OUT_CUBIC(progress);
|
|
|
|
edgeIds.forEach((edgeId) => {
|
|
if (this.activeDrawerEdges.includes(edgeId)) {
|
|
return;
|
|
}
|
|
|
|
const edge = this.edges.get(edgeId);
|
|
if (!edge) return;
|
|
|
|
const defaultColor =
|
|
edge.dashes || edge.label === "AND"
|
|
? "{{ CREWAI_ORANGE }}"
|
|
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
|
|
const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0;
|
|
const currentWidth =
|
|
edge.width !== undefined ? edge.width : CONSTANTS.EDGE.DEFAULT_WIDTH;
|
|
const currentShadowSize =
|
|
edge.shadow && edge.shadow.size !== undefined
|
|
? edge.shadow.size
|
|
: CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE;
|
|
|
|
const targetOpacity = 1.0;
|
|
const targetWidth = CONSTANTS.EDGE.DEFAULT_WIDTH;
|
|
const targetShadowSize = CONSTANTS.EDGE.DEFAULT_SHADOW_SIZE;
|
|
|
|
const newOpacity =
|
|
currentOpacity + (targetOpacity - currentOpacity) * eased;
|
|
const newWidth = currentWidth + (targetWidth - currentWidth) * eased;
|
|
const newShadowSize =
|
|
currentShadowSize + (targetShadowSize - currentShadowSize) * eased;
|
|
|
|
const updateData = {
|
|
id: edge.id,
|
|
hidden: false,
|
|
opacity: newOpacity,
|
|
width: newWidth,
|
|
color: {
|
|
color: defaultColor,
|
|
highlight: defaultColor,
|
|
hover: defaultColor,
|
|
inherit: false,
|
|
},
|
|
shadow: {
|
|
enabled: true,
|
|
color: "rgba(0,0,0,0.08)",
|
|
size: newShadowSize,
|
|
x: 1,
|
|
y: 1,
|
|
},
|
|
font: {
|
|
color: "transparent",
|
|
background: "transparent",
|
|
},
|
|
arrows: {
|
|
to: {
|
|
enabled: true,
|
|
scaleFactor: 0.8,
|
|
type: "triangle",
|
|
},
|
|
},
|
|
};
|
|
|
|
if (edge.dashes) {
|
|
const scale = Math.sqrt(newWidth / CONSTANTS.EDGE.DEFAULT_WIDTH);
|
|
updateData.dashes = [15 * scale, 10 * scale];
|
|
}
|
|
|
|
this.edges.update(updateData);
|
|
});
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animateEdges);
|
|
}
|
|
};
|
|
|
|
animateEdges();
|
|
|
|
this.highlightedNodes = [];
|
|
this.highlightedEdges = [];
|
|
|
|
this.canvas.style.transition = `opacity ${CONSTANTS.ANIMATION.DURATION}ms ease-out`;
|
|
this.canvas.style.opacity = "0";
|
|
setTimeout(() => {
|
|
this.canvas.classList.remove("visible");
|
|
this.canvas.style.opacity = "1";
|
|
this.canvas.style.transition = "";
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
this.network.redraw();
|
|
}, CONSTANTS.ANIMATION.DURATION);
|
|
}
|
|
}
|
|
|
|
class DrawerManager {
|
|
constructor(network, nodes, edges, animationManager, triggeredByHighlighter, networkManager) {
|
|
this.network = network;
|
|
this.nodes = nodes;
|
|
this.edges = edges;
|
|
this.animationManager = animationManager;
|
|
this.triggeredByHighlighter = triggeredByHighlighter;
|
|
this.networkManager = networkManager;
|
|
|
|
this.elements = {
|
|
drawer: document.getElementById("drawer"),
|
|
overlay: document.getElementById("drawer-overlay"),
|
|
title: document.getElementById("drawer-node-name"),
|
|
content: document.getElementById("drawer-content"),
|
|
openIdeButton: document.getElementById("drawer-open-ide"),
|
|
closeButton: document.getElementById("drawer-close"),
|
|
navControls: document.querySelector(".nav-controls"),
|
|
legendPanel: document.getElementById("legend-panel"),
|
|
};
|
|
|
|
this.activeNodeId = null;
|
|
this.activeEdges = [];
|
|
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
this.elements.overlay.addEventListener("click", () => this.close());
|
|
this.elements.closeButton.addEventListener("click", () => this.close());
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") {
|
|
this.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
open(nodeName, metadata) {
|
|
this.elements.title.textContent = nodeName;
|
|
this.setupIdeButton(metadata);
|
|
this.renderContent(nodeName, metadata);
|
|
this.animateOpen();
|
|
}
|
|
|
|
setupIdeButton(metadata) {
|
|
if (metadata.source_file && metadata.source_start_line) {
|
|
this.elements.openIdeButton.style.display = "flex";
|
|
this.elements.openIdeButton.onclick = () => this.openInIDE(metadata);
|
|
} else {
|
|
this.elements.openIdeButton.style.display = "none";
|
|
}
|
|
}
|
|
|
|
openInIDE(metadata) {
|
|
const filePath = metadata.source_file;
|
|
const lineNum = metadata.source_start_line;
|
|
const detectedIDE = this.detectIDE();
|
|
|
|
const ideUrls = {
|
|
pycharm: `pycharm://open?file=${filePath}&line=${lineNum}`,
|
|
vscode: `vscode://file/${filePath}:${lineNum}`,
|
|
jetbrains: `jetbrains://open?file=${encodeURIComponent(filePath)}&line=${lineNum}`,
|
|
auto: `pycharm://open?file=${filePath}&line=${lineNum}`,
|
|
};
|
|
|
|
const ideUrl = ideUrls[detectedIDE] || ideUrls.auto;
|
|
|
|
const link = document.createElement("a");
|
|
link.href = ideUrl;
|
|
link.target = "_blank";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
const fallbackText = `${filePath}:${lineNum}`;
|
|
navigator.clipboard.writeText(fallbackText).catch(() => {});
|
|
}
|
|
|
|
detectIDE() {
|
|
const savedIDE = localStorage.getItem("preferred_ide");
|
|
if (savedIDE) return savedIDE;
|
|
if (navigator.userAgent.includes("JetBrains")) return "jetbrains";
|
|
return "auto";
|
|
}
|
|
|
|
renderContent(nodeName, metadata) {
|
|
let content = "";
|
|
|
|
content += this.renderMetadata(metadata);
|
|
|
|
if (metadata.source_code) {
|
|
content += this.renderSourceCode(metadata);
|
|
}
|
|
|
|
this.elements.content.innerHTML = content;
|
|
this.attachContentEventListeners(nodeName);
|
|
|
|
// Initialize Lucide icons in the newly rendered drawer content
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
renderTriggerCondition(metadata) {
|
|
if (metadata.trigger_condition) {
|
|
return this.renderConditionTree(metadata.trigger_condition);
|
|
} else if (metadata.trigger_methods) {
|
|
const uniqueTriggers = [...new Set(metadata.trigger_methods)];
|
|
const grouped = this.groupByIdenticalAction(uniqueTriggers);
|
|
|
|
return `
|
|
<ul class="drawer-list">
|
|
${grouped.map((group) => {
|
|
if (group.items.length === 1) {
|
|
return `<li><span class="drawer-code-link" data-node-id="${group.items[0]}">${group.items[0]}</span></li>`;
|
|
} else {
|
|
const groupId = group.items.join(',');
|
|
return `
|
|
<li>
|
|
<div class="trigger-group" data-trigger-items="${groupId}" style="border-left: 2px solid var(--text-secondary); padding: 4px 0 4px 8px; margin: 2px 0; border-radius: 3px; cursor: pointer; transition: background 0.15s ease;">
|
|
<div class="trigger-group-label" style="font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; user-select: none;">
|
|
${group.items.length} routes <span style="opacity: 0.5; font-size: 9px;">→</span>
|
|
</div>
|
|
<div class="trigger-group-items" style="display: flex; flex-wrap: wrap; gap: 4px; pointer-events: none;">
|
|
${group.items.map((t) => `<span class="drawer-code" style="opacity: 0.7;">${t}</span>`).join("")}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
`;
|
|
}
|
|
}).join("")}
|
|
</ul>
|
|
`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
groupByIdenticalAction(triggerIds) {
|
|
const nodeData = '{{ nodeData }}';
|
|
const allEdges = this.edges.get();
|
|
const activeNodeId = this.activeNodeId;
|
|
|
|
const triggerPaths = new Map();
|
|
|
|
triggerIds.forEach(triggerId => {
|
|
const pathSignature = this.getPathSignature(triggerId, activeNodeId, allEdges, nodeData);
|
|
if (!triggerPaths.has(pathSignature)) {
|
|
triggerPaths.set(pathSignature, []);
|
|
}
|
|
triggerPaths.get(pathSignature).push(triggerId);
|
|
});
|
|
|
|
return Array.from(triggerPaths.values()).map(items => ({ items }));
|
|
}
|
|
|
|
getPathSignature(triggerNodeId, activeNodeId, allEdges, nodeData) {
|
|
const connectingEdges = [];
|
|
const direct = allEdges.filter(
|
|
(edge) => edge.from === triggerNodeId && edge.to === activeNodeId
|
|
);
|
|
if (direct.length > 0) {
|
|
return direct.map(e => e.id).sort().join(',');
|
|
}
|
|
|
|
const activeMetadata = nodeData[activeNodeId];
|
|
if (activeMetadata && activeMetadata.trigger_methods && activeMetadata.trigger_methods.includes(triggerNodeId)) {
|
|
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
|
|
if (nodeInfo.router_paths && nodeInfo.router_paths.includes(triggerNodeId)) {
|
|
const routerEdges = allEdges.filter(
|
|
(edge) => edge.from === nodeName && edge.dashes
|
|
);
|
|
|
|
const matchingEdge = routerEdges.find(edge => edge.label === triggerNodeId);
|
|
if (matchingEdge) {
|
|
if (matchingEdge.to === activeNodeId) {
|
|
return matchingEdge.id;
|
|
}
|
|
|
|
const pathToActive = allEdges.filter(
|
|
(edge) => edge.from === matchingEdge.to && edge.to === activeNodeId
|
|
);
|
|
|
|
if (pathToActive.length > 0) {
|
|
return [matchingEdge.id, ...pathToActive.map(e => e.id)].sort().join(',');
|
|
}
|
|
}
|
|
|
|
for (const routerEdge of routerEdges) {
|
|
if (routerEdge.to === activeNodeId) {
|
|
return routerEdge.id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return triggerNodeId;
|
|
}
|
|
|
|
renderConditionTree(condition, depth = 0) {
|
|
if (typeof condition === "string") {
|
|
return `<span class="drawer-code-link trigger-leaf" data-node-id="${condition}">${condition}</span>`;
|
|
}
|
|
|
|
if (condition.type === "AND" || condition.type === "OR") {
|
|
const conditionType = condition.type;
|
|
const color = conditionType === "AND" ? "{{ CREWAI_ORANGE }}" : "var(--text-secondary)";
|
|
const bgColor = conditionType === "AND" ? "rgba(255,90,80,0.08)" : "rgba(102,102,102,0.06)";
|
|
const hoverBg = conditionType === "AND" ? "rgba(255,90,80,0.15)" : "rgba(102,102,102,0.12)";
|
|
|
|
const triggerIds = this.extractTriggerIds(condition);
|
|
const triggerIdsJson = JSON.stringify(triggerIds).replace(/"/g, '"');
|
|
|
|
const stringChildren = condition.conditions.filter(c => typeof c === 'string');
|
|
const nonStringChildren = condition.conditions.filter(c => typeof c !== 'string');
|
|
|
|
let children = "";
|
|
|
|
if (nonStringChildren.length > 0) {
|
|
children += nonStringChildren.map(sub => this.renderConditionTree(sub, depth + 1)).join("");
|
|
}
|
|
|
|
if (stringChildren.length > 0) {
|
|
const grouped = this.groupByIdenticalAction(stringChildren);
|
|
children += grouped.map((group) => {
|
|
if (group.items.length === 1) {
|
|
return this.renderConditionTree(group.items[0], depth + 1);
|
|
} else {
|
|
const groupId = group.items.join(',');
|
|
const groupColor = conditionType === "AND" ? "{{ CREWAI_ORANGE }}" : "var(--text-secondary)";
|
|
const groupBgColor = conditionType === "AND" ? "rgba(255,90,80,0.08)" : "rgba(102,102,102,0.06)";
|
|
const groupHoverBg = conditionType === "AND" ? "rgba(255,90,80,0.15)" : "rgba(102,102,102,0.12)";
|
|
return `
|
|
<div class="trigger-group" data-trigger-items="${groupId}" style="border-left: 2px solid ${groupColor}; padding: 8px 0 8px 12px; margin: 4px 0; border-radius: 4px; cursor: pointer; transition: background 0.2s ease;" onmouseover="this.style.background='${groupHoverBg}'" onmouseout="this.style.background='transparent'">
|
|
<div class="trigger-group-label" style="color: ${groupColor}; font-size: 11px; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; background: ${groupBgColor}; padding: 3px 8px; border-radius: 3px; display: inline-flex; align-items: center; gap: 4px; user-select: none;">
|
|
${group.items.length} routes <i data-lucide="chevron-down" style="width: 14px; height: 14px; color: ${groupColor};"></i>
|
|
</div>
|
|
<div class="trigger-group-items" style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; pointer-events: none;">
|
|
${group.items.map((t) => `<span class="drawer-code-link trigger-leaf" style="opacity: 0.7; cursor: default;">${t}</span>`).join("")}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}).join("");
|
|
}
|
|
|
|
return `
|
|
<div class="condition-group" data-trigger-group="${triggerIdsJson}" style="border-left: 2px solid ${color}; padding: 8px 0 8px 12px; margin: 4px 0; transition: background 0.2s ease; position: relative; border-radius: 4px;" onmouseover="this.style.background='${hoverBg}'" onmouseout="this.style.background='transparent'">
|
|
<div class="condition-label" data-condition-label="${conditionType}" style="color: ${color}; font-size: 11px; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; background: ${bgColor}; padding: 3px 8px; border-radius: 3px; display: inline-flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;">
|
|
${conditionType} <i data-lucide="chevron-down" style="width: 14px; height: 14px; color: ${color};"></i>
|
|
</div>
|
|
<div class="condition-children" style="margin-top: 4px;">
|
|
${children}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
extractTriggerIds(condition) {
|
|
if (typeof condition === "string") {
|
|
return [condition];
|
|
}
|
|
|
|
if (condition.type === "AND" || condition.type === "OR") {
|
|
const ids = [];
|
|
condition.conditions.forEach(sub => {
|
|
ids.push(...this.extractTriggerIds(sub));
|
|
});
|
|
return ids;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
renderMetadata(metadata) {
|
|
console.log('renderMetadata called with:', metadata);
|
|
let metadataContent = "";
|
|
|
|
const nodeType = metadata.type || "unknown";
|
|
const typeBadgeColor =
|
|
nodeType === "start" || nodeType === "router"
|
|
? "{{ CREWAI_ORANGE }}"
|
|
: "{{ DARK_GRAY }}";
|
|
metadataContent += `
|
|
<div class="drawer-section">
|
|
<div class="drawer-section-title">Type</div>
|
|
<span class="drawer-badge" style="background: ${typeBadgeColor}; color: white;">${nodeType}</span>
|
|
</div>
|
|
`;
|
|
|
|
if (metadata.condition_type) {
|
|
const conditionColors = {
|
|
AND: { color: "{{ CREWAI_ORANGE }}", bg: "rgba(255,90,80,0.12)" },
|
|
IF: { color: "{{ CREWAI_ORANGE }}", bg: "rgba(255,90,80,0.18)" },
|
|
default: { color: "{{ GRAY }}", bg: "rgba(102,102,102,0.12)" },
|
|
};
|
|
|
|
const { color, bg } =
|
|
conditionColors[metadata.condition_type] || conditionColors.default;
|
|
|
|
metadataContent += `
|
|
<div class="drawer-section">
|
|
<div class="drawer-section-title">Condition</div>
|
|
<span class="drawer-badge" style="background: ${bg}; color: ${color};">${metadata.condition_type}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
console.log('Checking trigger data:', {
|
|
has_trigger_condition: !!metadata.trigger_condition,
|
|
has_trigger_methods: !!(metadata.trigger_methods && metadata.trigger_methods.length > 0)
|
|
});
|
|
|
|
if (metadata.trigger_condition || (metadata.trigger_methods && metadata.trigger_methods.length > 0)) {
|
|
console.log('Rendering Triggered By section');
|
|
metadataContent += `
|
|
<div class="drawer-section">
|
|
<div class="drawer-section-title">Triggered By</div>
|
|
${this.renderTriggerCondition(metadata)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (metadata.router_paths && metadata.router_paths.length > 0) {
|
|
const uniqueRouterPaths = [...new Set(metadata.router_paths)];
|
|
const routerPathsJson = JSON.stringify(uniqueRouterPaths).replace(/"/g, '"');
|
|
metadataContent += `
|
|
<div class="drawer-section">
|
|
<div class="drawer-section-title router-paths-title" data-router-paths="${routerPathsJson}" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
|
|
Router Paths <i data-lucide="chevron-down" style="width: 14px; height: 14px; color: var(--text-primary);"></i>
|
|
</div>
|
|
<ul class="drawer-list">
|
|
${uniqueRouterPaths.map((p) => `<li><span class="drawer-code-link" data-node-id="${p}" style="color: {{ CREWAI_ORANGE }}; border-color: rgba(255,90,80,0.3);">${p}</span></li>`).join("")}
|
|
</ul>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return metadataContent
|
|
? `<div class="drawer-metadata-grid">${metadataContent}</div>`
|
|
: "";
|
|
}
|
|
|
|
renderSourceCode(metadata) {
|
|
let lines = metadata.source_lines || metadata.source_code.split("\n");
|
|
if (metadata.source_lines) {
|
|
lines = lines.map((line) => line.replace(/\n$/, ""));
|
|
}
|
|
|
|
let minIndent = Infinity;
|
|
lines.forEach((line) => {
|
|
if (line.trim().length > 0) {
|
|
const match = line.match(/^\s*/);
|
|
const indent = match ? match[0].length : 0;
|
|
minIndent = Math.min(minIndent, indent);
|
|
}
|
|
});
|
|
|
|
const dedentedLines = lines.map((line) => {
|
|
return line.trim().length === 0 ? "" : line.substring(minIndent);
|
|
});
|
|
|
|
const startLine = metadata.source_start_line || 1;
|
|
const codeToHighlight = dedentedLines.join("\n").trim();
|
|
const highlightedCode = highlightPython(codeToHighlight);
|
|
|
|
const highlightedLines = highlightedCode.split("\n");
|
|
const numberedLines = highlightedLines
|
|
.map((line, index) => {
|
|
const lineNum = startLine + index;
|
|
return `<div class="code-line"><span class="line-number">${lineNum}</span>${line}</div>`;
|
|
})
|
|
.join("");
|
|
|
|
let classSection = "";
|
|
if (metadata.class_signature) {
|
|
const highlightedClass = highlightPython(metadata.class_signature);
|
|
let highlightedClassSignature = highlightedClass;
|
|
|
|
if (metadata.class_line_number) {
|
|
highlightedClassSignature = `<span class="line-number">${metadata.class_line_number}</span>${highlightedClass}`;
|
|
}
|
|
|
|
classSection = `
|
|
<div>
|
|
<div class="accordion-subheader accordion-title">Class</div>
|
|
<div class="drawer-section">
|
|
<div class="code-block-container">
|
|
<pre class="code-block language-python">${highlightedClassSignature}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="accordion-section expanded">
|
|
<div class="accordion-header">
|
|
<div class="accordion-title">Source Code</div>
|
|
<svg class="accordion-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="4,6 8,10 12,6" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</div>
|
|
<div class="accordion-content">
|
|
<div class="drawer-section">
|
|
<div class="code-block-container">
|
|
<button class="code-copy-button" data-code="${codeToHighlight.replace(/"/g, """)}">
|
|
<svg class="copy-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
|
<path d="M3 11V3a2 2 0 0 1 2-2h8" />
|
|
</svg>
|
|
<svg class="check-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3,8 6,11 13,4" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</button>
|
|
<pre class="code-block language-python">${numberedLines}</pre>
|
|
</div>
|
|
</div>
|
|
${classSection}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
attachContentEventListeners(nodeName) {
|
|
const copyButtons =
|
|
this.elements.content.querySelectorAll(".code-copy-button");
|
|
copyButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const codeText = button.getAttribute("data-code");
|
|
navigator.clipboard
|
|
.writeText(codeText)
|
|
.then(() => {
|
|
button.classList.add("copied");
|
|
setTimeout(() => button.classList.remove("copied"), 2000);
|
|
})
|
|
.catch((err) => console.error("Failed to copy:", err));
|
|
});
|
|
});
|
|
|
|
const accordionHeaders =
|
|
this.elements.content.querySelectorAll(".accordion-header");
|
|
accordionHeaders.forEach((header) => {
|
|
header.addEventListener("click", () => {
|
|
const accordionSection = header.closest(".accordion-section");
|
|
accordionSection.classList.toggle("expanded");
|
|
});
|
|
});
|
|
|
|
const triggeredByLinks = this.elements.content.querySelectorAll(
|
|
".drawer-code-link[data-node-id]",
|
|
);
|
|
triggeredByLinks.forEach((link) => {
|
|
link.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const triggerNodeId = link.getAttribute("data-node-id");
|
|
this.triggeredByHighlighter.highlightTriggeredBy(triggerNodeId);
|
|
});
|
|
});
|
|
|
|
const triggerGroups = this.elements.content.querySelectorAll(
|
|
".trigger-group[data-trigger-items]",
|
|
);
|
|
triggerGroups.forEach((group) => {
|
|
group.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
const triggerItems = group.getAttribute("data-trigger-items").split(',');
|
|
this.triggeredByHighlighter.highlightTriggeredByGroup(triggerItems);
|
|
});
|
|
});
|
|
|
|
const conditionGroups = this.elements.content.querySelectorAll(
|
|
".condition-group[data-trigger-group]",
|
|
);
|
|
conditionGroups.forEach((group) => {
|
|
group.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const triggerIds = JSON.parse(group.getAttribute("data-trigger-group"));
|
|
this.triggeredByHighlighter.highlightTriggeredByGroup(triggerIds);
|
|
});
|
|
});
|
|
|
|
const routerPathsTitle = this.elements.content.querySelector(
|
|
".router-paths-title[data-router-paths]",
|
|
);
|
|
if (routerPathsTitle) {
|
|
routerPathsTitle.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.triggeredByHighlighter.highlightAllRouterPaths();
|
|
});
|
|
}
|
|
}
|
|
|
|
animateOpen() {
|
|
this.elements.drawer.style.visibility = "visible";
|
|
const wasAlreadyOpen = this.elements.drawer.classList.contains("open");
|
|
|
|
if (!wasAlreadyOpen) {
|
|
// Save current position and scale before opening drawer
|
|
const currentPosition = this.networkManager.network.getViewPosition();
|
|
const currentScale = this.networkManager.network.getScale();
|
|
this.networkManager.positionBeforeDrawer = {
|
|
position: currentPosition,
|
|
scale: currentScale
|
|
};
|
|
|
|
const targetPosition = this.networkManager.calculateNetworkPosition(true);
|
|
this.elements.drawer.classList.add("open");
|
|
this.elements.overlay.classList.add("visible");
|
|
this.elements.navControls.classList.add("drawer-open");
|
|
this.elements.legendPanel.classList.add("drawer-open");
|
|
this.networkManager.animateToPosition(targetPosition);
|
|
} else {
|
|
this.elements.drawer.classList.add("open");
|
|
this.elements.overlay.classList.add("visible");
|
|
this.elements.navControls.classList.add("drawer-open");
|
|
this.elements.legendPanel.classList.add("drawer-open");
|
|
}
|
|
}
|
|
|
|
close() {
|
|
// Animate accordions closed before removing classes
|
|
const accordions = this.elements.drawer.querySelectorAll(".accordion-section.expanded");
|
|
accordions.forEach(accordion => {
|
|
const content = accordion.querySelector(".accordion-content");
|
|
if (content) {
|
|
// Set explicit height for smooth animation
|
|
content.style.height = content.scrollHeight + "px";
|
|
// Force reflow
|
|
content.offsetHeight;
|
|
// Trigger collapse animation
|
|
content.style.height = "0px";
|
|
}
|
|
// Remove expanded class after animation
|
|
setTimeout(() => {
|
|
accordion.classList.remove("expanded");
|
|
if (content) {
|
|
content.style.height = "";
|
|
}
|
|
}, CONSTANTS.ANIMATION.DURATION);
|
|
});
|
|
|
|
this.elements.drawer.classList.remove("open");
|
|
this.elements.overlay.classList.remove("visible");
|
|
this.elements.navControls.classList.remove("drawer-open");
|
|
this.elements.legendPanel.classList.remove("drawer-open");
|
|
|
|
if (this.activeNodeId) {
|
|
this.activeEdges.forEach((edgeId) => {
|
|
this.animationManager.animateEdgeWidth(
|
|
this.edges,
|
|
edgeId,
|
|
CONSTANTS.EDGE.DEFAULT_WIDTH,
|
|
CONSTANTS.ANIMATION.DURATION,
|
|
);
|
|
});
|
|
this.activeNodeId = null;
|
|
this.activeEdges = [];
|
|
}
|
|
|
|
this.triggeredByHighlighter.clear();
|
|
this.elements.drawer.offsetHeight;
|
|
|
|
// Restore the position before the drawer was opened
|
|
if (this.networkManager.positionBeforeDrawer) {
|
|
this.networkManager.animateToPosition(this.networkManager.positionBeforeDrawer);
|
|
this.networkManager.positionBeforeDrawer = null;
|
|
} else {
|
|
this.networkManager.fitToAvailableSpace();
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (!this.elements.drawer.classList.contains("open")) {
|
|
this.elements.drawer.style.visibility = "hidden";
|
|
}
|
|
}, CONSTANTS.ANIMATION.DURATION);
|
|
}
|
|
|
|
setActiveNode(nodeId, connectedEdges) {
|
|
this.activeNodeId = nodeId;
|
|
this.activeEdges = connectedEdges;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Network Manager
|
|
// ============================================================================
|
|
|
|
class NetworkManager {
|
|
constructor() {
|
|
this.network = null;
|
|
this.nodes = null;
|
|
this.edges = null;
|
|
this.animationManager = new AnimationManager();
|
|
this.drawerManager = null;
|
|
this.triggeredByHighlighter = null;
|
|
|
|
this.hoveredNodeId = null;
|
|
this.pressedNodeId = null;
|
|
this.pressedEdges = [];
|
|
this.isClicking = false;
|
|
this.positionBeforeDrawer = null;
|
|
}
|
|
|
|
async initialize() {
|
|
try {
|
|
await loadVisCDN();
|
|
|
|
this.nodes = new vis.DataSet('{{ nodes_list_json }}');
|
|
this.edges = new vis.DataSet('{{ edges_list_json }}');
|
|
|
|
const container = document.getElementById("network");
|
|
const options = this.createNetworkOptions();
|
|
|
|
this.network = new vis.Network(
|
|
container,
|
|
{ nodes: this.nodes, edges: this.edges },
|
|
options,
|
|
);
|
|
|
|
this.triggeredByHighlighter = new TriggeredByHighlighter(
|
|
this.network,
|
|
this.nodes,
|
|
this.edges,
|
|
document.getElementById("highlight-canvas"),
|
|
);
|
|
|
|
this.drawerManager = new DrawerManager(
|
|
this.network,
|
|
this.nodes,
|
|
this.edges,
|
|
this.animationManager,
|
|
this.triggeredByHighlighter,
|
|
this,
|
|
);
|
|
|
|
this.setupEventListeners();
|
|
this.setupControls();
|
|
this.setupTheme();
|
|
|
|
this.network.once("stabilizationIterationsDone", () => {
|
|
this.fitToAvailableSpace(true);
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to initialize network:", error);
|
|
}
|
|
}
|
|
|
|
createNetworkOptions() {
|
|
this.nodeRenderer = new NodeRenderer(this.nodes, this);
|
|
const nodesArray = this.nodes.get();
|
|
const hasExplicitPositions = nodesArray.some(node =>
|
|
node.x !== undefined && node.y !== undefined
|
|
);
|
|
|
|
return {
|
|
nodes: {
|
|
shape: "custom",
|
|
shadow: false,
|
|
chosen: false,
|
|
size: 30,
|
|
ctxRenderer: (params) => this.nodeRenderer.render(params),
|
|
scaling: {
|
|
min: 1,
|
|
max: 100,
|
|
},
|
|
},
|
|
edges: {
|
|
width: CONSTANTS.EDGE.DEFAULT_WIDTH,
|
|
hoverWidth: 0,
|
|
labelHighlightBold: false,
|
|
shadow: false,
|
|
smooth: {
|
|
enabled: true,
|
|
type: "cubicBezier",
|
|
roundness: 0.35,
|
|
forceDirection: 'vertical',
|
|
},
|
|
font: {
|
|
size: 13,
|
|
align: "middle",
|
|
color: "transparent",
|
|
face: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
strokeWidth: 0,
|
|
background: "transparent",
|
|
vadjust: 0,
|
|
},
|
|
arrows: {
|
|
to: {
|
|
enabled: true,
|
|
scaleFactor: 0.8,
|
|
type: "triangle",
|
|
},
|
|
},
|
|
arrowStrikethrough: true,
|
|
chosen: {
|
|
edge: false,
|
|
label: false,
|
|
},
|
|
},
|
|
physics: {
|
|
enabled: false,
|
|
hierarchicalRepulsion: {
|
|
nodeDistance: CONSTANTS.NETWORK.NODE_DISTANCE,
|
|
centralGravity: 0.0,
|
|
springLength: CONSTANTS.NETWORK.SPRING_LENGTH,
|
|
springConstant: 0.01,
|
|
damping: 0.09,
|
|
},
|
|
solver: "hierarchicalRepulsion",
|
|
stabilization: {
|
|
enabled: false,
|
|
iterations: CONSTANTS.NETWORK.STABILIZATION_ITERATIONS,
|
|
updateInterval: 25,
|
|
},
|
|
},
|
|
layout: {
|
|
hierarchical: {
|
|
enabled: !hasExplicitPositions,
|
|
direction: "UD",
|
|
sortMethod: "directed",
|
|
levelSeparation: CONSTANTS.NETWORK.LEVEL_SEPARATION,
|
|
nodeSpacing: CONSTANTS.NETWORK.NODE_SPACING,
|
|
treeSpacing: CONSTANTS.NETWORK.TREE_SPACING,
|
|
edgeMinimization: false,
|
|
blockShifting: true,
|
|
parentCentralization: true,
|
|
},
|
|
},
|
|
interaction: {
|
|
hover: true,
|
|
hoverConnectedEdges: false,
|
|
navigationButtons: false,
|
|
keyboard: true,
|
|
selectConnectedEdges: false,
|
|
multiselect: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
setupEventListeners() {
|
|
this.network.on("hoverNode", (params) => {
|
|
this.hoveredNodeId = params.node;
|
|
document.body.style.cursor = "pointer";
|
|
this.network.redraw();
|
|
});
|
|
|
|
this.network.on("blurNode", (params) => {
|
|
if (this.hoveredNodeId === params.node) {
|
|
this.hoveredNodeId = null;
|
|
}
|
|
document.body.style.cursor = "default";
|
|
this.network.redraw();
|
|
});
|
|
|
|
this.network.on("selectNode", (params) => {
|
|
if (params.nodes.length > 0) {
|
|
this.pressedNodeId = params.nodes[0];
|
|
this.pressedEdges = this.network.getConnectedEdges(this.pressedNodeId);
|
|
this.network.redraw();
|
|
}
|
|
});
|
|
|
|
this.network.on("deselectNode", () => {
|
|
if (this.pressedNodeId) {
|
|
setTimeout(() => {
|
|
if (this.isClicking) {
|
|
this.isClicking = false;
|
|
this.pressedNodeId = null;
|
|
this.pressedEdges = [];
|
|
return;
|
|
}
|
|
|
|
this.pressedEdges.forEach((edgeId) => {
|
|
if (!this.drawerManager.activeEdges.includes(edgeId)) {
|
|
this.animationManager.animateEdgeWidth(
|
|
this.edges,
|
|
edgeId,
|
|
CONSTANTS.EDGE.DEFAULT_WIDTH,
|
|
150,
|
|
);
|
|
}
|
|
});
|
|
|
|
this.pressedNodeId = null;
|
|
this.pressedEdges = [];
|
|
this.network.redraw();
|
|
}, 10);
|
|
}
|
|
});
|
|
|
|
this.network.on("click", (params) => {
|
|
if (params.nodes.length > 0) {
|
|
this.pressedNodeId = params.nodes[0];
|
|
this.network.redraw();
|
|
this.handleNodeClick(params.nodes[0]);
|
|
} else if (params.edges.length === 0) {
|
|
this.pressedNodeId = null;
|
|
this.network.redraw();
|
|
this.drawerManager.close();
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
handleNodeClick(nodeId) {
|
|
const node = this.nodes.get(nodeId);
|
|
const nodeData = '{{ nodeData }}';
|
|
const metadata = nodeData[nodeId];
|
|
|
|
this.isClicking = true;
|
|
if (this.drawerManager && this.drawerManager.activeNodeId === nodeId) {
|
|
this.drawerManager.close();
|
|
return;
|
|
}
|
|
|
|
const connectedEdges = this.network.getConnectedEdges(nodeId);
|
|
|
|
const allEdges = this.edges.get();
|
|
const connectedNodeIds = new Set([nodeId]);
|
|
|
|
connectedEdges.forEach((edgeId) => {
|
|
const edge = allEdges.find(e => e.id === edgeId);
|
|
if (edge) {
|
|
if (edge.from === nodeId) {
|
|
connectedNodeIds.add(edge.to);
|
|
} else if (edge.to === nodeId) {
|
|
connectedNodeIds.add(edge.from);
|
|
}
|
|
}
|
|
});
|
|
const allNodes = this.nodes.get();
|
|
allNodes.forEach((n) => {
|
|
this.nodes.update({ id: n.id, opacity: 1.0 });
|
|
});
|
|
this.triggeredByHighlighter.highlightedNodes = [];
|
|
this.triggeredByHighlighter.highlightedEdges = [];
|
|
|
|
// Animate all edges back to default, excluding the ones we'll highlight
|
|
this.triggeredByHighlighter.resetEdgesToDefault(null, connectedEdges);
|
|
|
|
this.drawerManager.setActiveNode(nodeId, connectedEdges);
|
|
this.triggeredByHighlighter.setActiveDrawer(nodeId, connectedEdges);
|
|
|
|
setTimeout(() => {
|
|
connectedEdges.forEach((edgeId) => {
|
|
this.animationManager.animateEdgeWidth(this.edges, edgeId, 5, 200);
|
|
});
|
|
}, 15);
|
|
|
|
this.drawerManager.open(nodeId, metadata);
|
|
this.network.redraw();
|
|
}
|
|
|
|
setupControls() {
|
|
const ideSelector = document.getElementById("ide-selector");
|
|
const savedIDE = localStorage.getItem("preferred_ide") || "auto";
|
|
ideSelector.value = savedIDE;
|
|
ideSelector.addEventListener("change", function () {
|
|
localStorage.setItem("preferred_ide", this.value);
|
|
});
|
|
|
|
document.getElementById("zoom-in").addEventListener("click", () => {
|
|
const scale = this.network.getScale();
|
|
this.network.moveTo({
|
|
scale: scale * 1.2,
|
|
animation: { duration: 200, easingFunction: "easeInOutQuad" },
|
|
});
|
|
});
|
|
|
|
document.getElementById("zoom-out").addEventListener("click", () => {
|
|
const scale = this.network.getScale();
|
|
this.network.moveTo({
|
|
scale: scale * 0.8,
|
|
animation: { duration: 200, easingFunction: "easeInOutQuad" },
|
|
});
|
|
});
|
|
|
|
document.getElementById("fit").addEventListener("click", () => {
|
|
this.fitToAvailableSpace();
|
|
});
|
|
|
|
this.setupExportControls();
|
|
}
|
|
|
|
setupExportControls() {
|
|
document.getElementById("export-png").addEventListener("click", () => {
|
|
const script = document.createElement("script");
|
|
script.src =
|
|
"https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js";
|
|
script.onload = () => {
|
|
html2canvas(document.getElementById("network-container")).then(
|
|
(canvas) => {
|
|
const link = document.createElement("a");
|
|
link.download = "flow.png";
|
|
link.href = canvas.toDataURL();
|
|
link.click();
|
|
},
|
|
);
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
|
|
document.getElementById("export-pdf").addEventListener("click", () => {
|
|
const script1 = document.createElement("script");
|
|
script1.src =
|
|
"https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js";
|
|
script1.onload = () => {
|
|
const script2 = document.createElement("script");
|
|
script2.src =
|
|
"https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js";
|
|
script2.onload = () => {
|
|
html2canvas(document.getElementById("network-container")).then(
|
|
(canvas) => {
|
|
const imgData = canvas.toDataURL("image/png");
|
|
const { jsPDF } = window.jspdf;
|
|
const pdf = new jsPDF({
|
|
orientation:
|
|
canvas.width > canvas.height ? "landscape" : "portrait",
|
|
unit: "px",
|
|
format: [canvas.width, canvas.height],
|
|
});
|
|
pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height);
|
|
pdf.save("flow.pdf");
|
|
},
|
|
);
|
|
};
|
|
document.head.appendChild(script2);
|
|
};
|
|
document.head.appendChild(script1);
|
|
});
|
|
|
|
// document.getElementById("export-json").addEventListener("click", () => {
|
|
// const dagData = '{{ dagData }}';
|
|
// const dataStr = JSON.stringify(dagData, null, 2);
|
|
// const blob = new Blob([dataStr], { type: "application/json" });
|
|
// const url = URL.createObjectURL(blob);
|
|
// const link = document.createElement("a");
|
|
// link.download = "flow_dag.json";
|
|
// link.href = url;
|
|
// link.click();
|
|
// URL.revokeObjectURL(url);
|
|
// });
|
|
}
|
|
|
|
calculateNetworkPosition(isDrawerOpen, centerScreen = false) {
|
|
const infoBox = document.getElementById("info");
|
|
const infoRect = infoBox.getBoundingClientRect();
|
|
const leftEdge = infoRect.right + 40; // 40px padding after legend
|
|
const rightEdge = isDrawerOpen ? window.innerWidth - CONSTANTS.DRAWER.WIDTH - 40 : window.innerWidth - 40;
|
|
const availableWidth = rightEdge - leftEdge;
|
|
|
|
// Use true screen center for initial position, otherwise use available space center
|
|
const canvas = this.network ? this.network.canvas.frame.canvas : document.getElementById("network");
|
|
const canvasRect = canvas.getBoundingClientRect();
|
|
const domCenterX = centerScreen ? canvasRect.left + canvas.clientWidth / 2 : leftEdge + (availableWidth / 2);
|
|
|
|
const nodePositions = this.network.getPositions();
|
|
const nodeIds = Object.keys(nodePositions);
|
|
|
|
if (nodeIds.length === 0) return null;
|
|
const canvasWidth = canvas.clientWidth;
|
|
const canvasHeight = canvas.clientHeight;
|
|
|
|
const padding = 30;
|
|
const maxNodeWidth = 200;
|
|
const maxNodeHeight = 60;
|
|
|
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
nodeIds.forEach(id => {
|
|
const pos = nodePositions[id];
|
|
minX = Math.min(minX, pos.x - maxNodeWidth / 2);
|
|
maxX = Math.max(maxX, pos.x + maxNodeWidth / 2);
|
|
minY = Math.min(minY, pos.y - maxNodeHeight / 2);
|
|
maxY = Math.max(maxY, pos.y + maxNodeHeight / 2);
|
|
});
|
|
|
|
const networkWidth = maxX - minX;
|
|
const networkHeight = maxY - minY;
|
|
const networkCenterX = (minX + maxX) / 2;
|
|
const networkCenterY = (minY + maxY) / 2;
|
|
const scaleX = availableWidth / (networkWidth + padding * 2);
|
|
const scaleY = (canvasHeight - padding * 2) / (networkHeight + padding * 2);
|
|
const scale = Math.min(scaleX, scaleY);
|
|
const targetDOMX = domCenterX;
|
|
const canvasCenterDOMX = canvasRect.left + canvasWidth / 2;
|
|
const domShift = targetDOMX - canvasCenterDOMX;
|
|
const networkShift = domShift / scale;
|
|
|
|
return {
|
|
position: {
|
|
x: networkCenterX - networkShift,
|
|
y: networkCenterY,
|
|
},
|
|
scale: scale,
|
|
};
|
|
}
|
|
|
|
animateToPosition(targetPosition) {
|
|
if (!targetPosition) return;
|
|
|
|
this.network.moveTo({
|
|
position: targetPosition.position,
|
|
scale: targetPosition.scale,
|
|
animation: {
|
|
duration: 300,
|
|
easingFunction: "easeInOutCubic"
|
|
},
|
|
});
|
|
}
|
|
|
|
fitToAvailableSpace(centerScreen = false) {
|
|
const drawer = document.getElementById("drawer");
|
|
const isDrawerOpen = drawer.classList.contains("open");
|
|
const targetPosition = this.calculateNetworkPosition(isDrawerOpen, centerScreen);
|
|
this.animateToPosition(targetPosition);
|
|
}
|
|
|
|
setupTheme() {
|
|
const themeToggle = document.getElementById("theme-toggle");
|
|
const htmlElement = document.documentElement;
|
|
|
|
const updateEdgeColors = () => {
|
|
const orEdgeColor = getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
|
|
|
|
this.edges.forEach((edge) => {
|
|
let edgeColor;
|
|
if (edge.dashes || edge.label === "AND") {
|
|
edgeColor = "{{ CREWAI_ORANGE }}";
|
|
} else {
|
|
edgeColor = orEdgeColor;
|
|
}
|
|
|
|
const updateData = {
|
|
id: edge.id,
|
|
color: {
|
|
color: edgeColor,
|
|
highlight: edgeColor,
|
|
},
|
|
font: {
|
|
color: "transparent",
|
|
background: "transparent",
|
|
},
|
|
};
|
|
|
|
this.edges.update(updateData);
|
|
});
|
|
|
|
this.network.redraw();
|
|
};
|
|
|
|
const updateThemeIcon = (isDark) => {
|
|
const iconName = isDark ? 'sun' : 'moon';
|
|
themeToggle.title = isDark ? "Toggle Light Mode" : "Toggle Dark Mode";
|
|
|
|
// Replace the icon HTML entirely and reinitialize Lucide
|
|
themeToggle.innerHTML = `<i data-lucide="${iconName}" style="width: 18px; height: 18px;"></i>`;
|
|
|
|
// Reinitialize Lucide icons for the specific button
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons({
|
|
elements: themeToggle.querySelectorAll('[data-lucide]')
|
|
});
|
|
}
|
|
};
|
|
|
|
// Set up click handler FIRST before any icon updates
|
|
themeToggle.addEventListener("click", () => {
|
|
const currentTheme = htmlElement.getAttribute("data-theme");
|
|
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
|
|
|
if (newTheme === "dark") {
|
|
htmlElement.setAttribute("data-theme", "dark");
|
|
updateThemeIcon(true);
|
|
} else {
|
|
htmlElement.removeAttribute("data-theme");
|
|
updateThemeIcon(false);
|
|
}
|
|
|
|
localStorage.setItem("theme", newTheme);
|
|
|
|
// Clear color cache to ensure theme-dependent colors are recalculated
|
|
if (this.nodeRenderer) {
|
|
this.nodeRenderer.colorCache.clear();
|
|
}
|
|
|
|
// Update edge colors and redraw network with new theme
|
|
setTimeout(updateEdgeColors, 50);
|
|
});
|
|
|
|
// Initialize theme after click handler is set up
|
|
const savedTheme = localStorage.getItem("theme") || "dark";
|
|
if (savedTheme === "dark") {
|
|
htmlElement.setAttribute("data-theme", "dark");
|
|
updateThemeIcon(true);
|
|
setTimeout(updateEdgeColors, 0);
|
|
} else {
|
|
updateThemeIcon(false);
|
|
setTimeout(updateEdgeColors, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Application Entry Point
|
|
// ============================================================================
|
|
|
|
(async () => {
|
|
// Initialize Lucide icons first (before theme setup)
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
const networkManager = new NetworkManager();
|
|
await networkManager.initialize();
|
|
|
|
// Re-initialize Lucide icons after theme is set up
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
})();
|