diff --git a/src/ir/ir.ts b/src/ir/ir.ts index 1927c78..f8aa498 100644 --- a/src/ir/ir.ts +++ b/src/ir/ir.ts @@ -71,6 +71,13 @@ export interface LocationMapEntry { nodeId: string; } +export interface AnimationPath { + id: string; + nodes: string[]; + edges: string[]; + description: string; +} + export interface FlowchartIR { nodes: FlowchartNode[]; edges: FlowchartEdge[]; @@ -84,4 +91,5 @@ export interface FlowchartIR { rating: "low" | "medium" | "high" | "very-high"; description: string; }; + animationPaths?: AnimationPath[]; } \ No newline at end of file diff --git a/src/logic/EnhancedMermaidGenerator.ts b/src/logic/EnhancedMermaidGenerator.ts index c24d56e..5e63e30 100644 --- a/src/logic/EnhancedMermaidGenerator.ts +++ b/src/logic/EnhancedMermaidGenerator.ts @@ -2,6 +2,7 @@ import { FlowchartIR, FlowchartNode, FlowchartEdge, NodeType } from "../ir/ir"; import { StringProcessor } from "./utils/StringProcessor"; import { SubtleThemeManager, ThemeStyles } from "./utils/ThemeManager"; import { getComplexityConfig } from "./utils/ComplexityConfig"; +import { AnimationPathGenerator } from "./utils/AnimationPathGenerator"; // Optimized string building class StringBuilder { @@ -89,6 +90,26 @@ export class EnhancedMermaidGenerator { this.sb.appendLine(`%% ${ir.functionComplexity.description}`); } + // Generate animation paths if not already present + if (!ir.animationPaths) { + try { + ir.animationPaths = AnimationPathGenerator.generatePaths(ir); + } catch (error) { + console.warn('Failed to generate animation paths:', error); + ir.animationPaths = []; + } + } + + // Add animation path metadata as comments + if (ir.animationPaths && ir.animationPaths.length > 0) { + this.sb.appendLine(""); + this.sb.appendLine(" %% Animation paths metadata"); + for (const path of ir.animationPaths) { + this.sb.appendLine(` %% ANIM_PATH:${path.id}:${path.nodes.join(',')}`); + this.sb.appendLine(` %% ANIM_DESC:${path.id}:${path.description}`); + } + } + // Apply sanitization to all IDs first to ensure consistency for (const node of ir.nodes) { node.id = this.sanitizeId(node.id); diff --git a/src/logic/utils/AnimationPathGenerator.ts b/src/logic/utils/AnimationPathGenerator.ts new file mode 100644 index 0000000..e93c778 --- /dev/null +++ b/src/logic/utils/AnimationPathGenerator.ts @@ -0,0 +1,299 @@ +import { FlowchartIR, FlowchartNode, FlowchartEdge, NodeType } from "../../ir/ir"; + +export interface AnimationPath { + id: string; + nodes: string[]; + edges: string[]; + description: string; +} + +export interface AnimationPathStep { + nodeId: string; + edgeId?: string; + isLoopIteration?: boolean; +} + +export class AnimationPathGenerator { + private static readonly MAX_LOOP_ITERATIONS = 2; + private static readonly MAX_PATH_LENGTH = 50; // Prevent infinite paths + + /** + * Generate all possible execution paths through the flowchart + */ + public static generatePaths(ir: FlowchartIR): AnimationPath[] { + if (!ir.entryNodeId || ir.nodes.length === 0) { + console.log('AnimationPathGenerator: No entry node or empty nodes'); + return []; + } + + try { + const paths: AnimationPath[] = []; + const visited = new Set(); + const pathSteps: AnimationPathStep[] = []; + const nodeMap = new Map(); + const edgeMap = new Map(); + + // Build lookup maps + for (const node of ir.nodes) { + nodeMap.set(node.id, node); + } + + for (const edge of ir.edges) { + if (!edgeMap.has(edge.from)) { + edgeMap.set(edge.from, []); + } + edgeMap.get(edge.from)!.push(edge); + } + + console.log(`AnimationPathGenerator: Starting path generation from ${ir.entryNodeId}`); + + // Generate paths starting from entry node + this.generatePathsFromNode( + ir.entryNodeId, + nodeMap, + edgeMap, + paths, + visited, + pathSteps, + 0 + ); + + console.log(`AnimationPathGenerator: Generated ${paths.length} paths`); + return paths.length > 0 ? paths : this.generateFallbackPath(ir); + } catch (error) { + console.error('AnimationPathGenerator: Error generating paths:', error); + return this.generateFallbackPath(ir); + } + } + + private static generatePathsFromNode( + nodeId: string, + nodeMap: Map, + edgeMap: Map, + paths: AnimationPath[], + visited: Set, + pathSteps: AnimationPathStep[], + depth: number + ): void { + // Prevent infinite recursion + if (depth > this.MAX_PATH_LENGTH) { + console.log(`AnimationPathGenerator: Max depth reached for node ${nodeId}`); + return; + } + + // Prevent cycles + if (visited.has(nodeId)) { + console.log(`AnimationPathGenerator: Cycle detected at node ${nodeId}`); + return; + } + + const node = nodeMap.get(nodeId); + if (!node) { + console.log(`AnimationPathGenerator: Node ${nodeId} not found`); + return; + } + + // Add current node to path and visited set + pathSteps.push({ nodeId }); + visited.add(nodeId); + + // Check if this is an exit node + if (this.isExitNode(node)) { + this.createPathFromSteps(pathSteps, paths, nodeMap); + pathSteps.pop(); + visited.delete(nodeId); + return; + } + + // Get outgoing edges + const outgoingEdges = edgeMap.get(nodeId) || []; + + if (outgoingEdges.length === 0) { + // Dead end - create path + this.createPathFromSteps(pathSteps, paths, nodeMap); + pathSteps.pop(); + visited.delete(nodeId); + return; + } + + // Handle different node types + if (this.isDecisionNode(node)) { + // For decision nodes, create separate paths for each branch + for (const edge of outgoingEdges) { + const edgeId = `${edge.from}_${edge.to}`; + pathSteps[pathSteps.length - 1].edgeId = edgeId; + + this.generatePathsFromNode( + edge.to, + nodeMap, + edgeMap, + paths, + new Set(visited), // Create new visited set for each branch + [...pathSteps], // Create new path steps array + depth + 1 + ); + } + } else if (this.isLoopNode(node)) { + // Handle loops with limited iterations + const loopIterations = Math.min(outgoingEdges.length, this.MAX_LOOP_ITERATIONS); + + for (let i = 0; i < loopIterations; i++) { + const edge = outgoingEdges[0]; // Assume first edge is the loop continuation + const edgeId = `${edge.from}_${edge.to}`; + + pathSteps[pathSteps.length - 1].edgeId = edgeId; + pathSteps[pathSteps.length - 1].isLoopIteration = i > 0; + + this.generatePathsFromNode( + edge.to, + nodeMap, + edgeMap, + paths, + new Set(visited), + [...pathSteps], + depth + 1 + ); + } + } else { + // Regular node - follow first outgoing edge + const edge = outgoingEdges[0]; + const edgeId = `${edge.from}_${edge.to}`; + + pathSteps[pathSteps.length - 1].edgeId = edgeId; + + this.generatePathsFromNode( + edge.to, + nodeMap, + edgeMap, + paths, + visited, + pathSteps, + depth + 1 + ); + } + + pathSteps.pop(); + visited.delete(nodeId); + } + + private static isExitNode(node: FlowchartNode): boolean { + return ( + node.nodeType === NodeType.EXIT || + node.nodeType === NodeType.RETURN || + node.label.toLowerCase().includes('return') || + node.label.toLowerCase().includes('exit') + ); + } + + private static isDecisionNode(node: FlowchartNode): boolean { + return ( + node.nodeType === NodeType.DECISION || + node.shape === 'diamond' || + node.label.toLowerCase().includes('if') || + node.label.toLowerCase().includes('else') || + node.label.toLowerCase().includes('switch') || + node.label.toLowerCase().includes('case') + ); + } + + private static isLoopNode(node: FlowchartNode): boolean { + return ( + node.nodeType === NodeType.LOOP_START || + node.nodeType === NodeType.LOOP_END || + node.label.toLowerCase().includes('for') || + node.label.toLowerCase().includes('while') || + node.label.toLowerCase().includes('loop') + ); + } + + private static createPathFromSteps( + steps: AnimationPathStep[], + paths: AnimationPath[], + nodeMap: Map + ): void { + if (steps.length === 0) { + return; + } + + const pathId = `path_${paths.length}`; + const nodes: string[] = []; + const edges: string[] = []; + let description = ''; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + nodes.push(step.nodeId); + + if (step.edgeId) { + edges.push(step.edgeId); + } + + // Build description + const node = nodeMap.get(step.nodeId); + if (node) { + if (i === 0) { + description = `Start: ${node.label}`; + } else if (step.isLoopIteration) { + description += ` → [Loop] ${node.label}`; + } else { + description += ` → ${node.label}`; + } + } + } + + // Add end marker + if (steps.length > 1) { + description += ' → End'; + } + + paths.push({ + id: pathId, + nodes, + edges, + description + }); + } + + private static generateFallbackPath(ir: FlowchartIR): AnimationPath[] { + // If no paths were generated, create a simple linear path + const nodes = ir.nodes.map(n => n.id); + const edges: string[] = []; + + // Create edges between consecutive nodes + for (let i = 0; i < nodes.length - 1; i++) { + edges.push(`${nodes[i]}_${nodes[i + 1]}`); + } + + return [{ + id: 'path_0', + nodes, + edges, + description: `Linear path: ${nodes.join(' → ')}` + }]; + } + + /** + * Get a simplified path description for UI display + */ + public static getPathDescription(path: AnimationPath, maxLength: number = 50): string { + if (path.description.length <= maxLength) { + return path.description; + } + + return path.description.substring(0, maxLength - 3) + '...'; + } + + /** + * Check if a path contains loops + */ + public static hasLoops(path: AnimationPath): boolean { + const nodeCounts = new Map(); + + for (const nodeId of path.nodes) { + const count = nodeCounts.get(nodeId) || 0; + nodeCounts.set(nodeId, count + 1); + } + + return Array.from(nodeCounts.values()).some(count => count > 1); + } +} diff --git a/src/view/BaseFlowchartProvider.ts b/src/view/BaseFlowchartProvider.ts index bfd57d1..1bd1179 100644 --- a/src/view/BaseFlowchartProvider.ts +++ b/src/view/BaseFlowchartProvider.ts @@ -52,6 +52,21 @@ export type SetupLLMMessage = { payload: {}; }; +export type StartAnimationMessage = { + command: "startAnimation"; + payload: {}; +}; + +export type StopAnimationMessage = { + command: "stopAnimation"; + payload: {}; +}; + +export type SwitchPathMessage = { + command: "switchPath"; + payload: { pathIndex: number }; +}; + export type WebviewMessage = | HighlightCodeMessage | ExportMessage @@ -60,7 +75,10 @@ export type WebviewMessage = | CopyMermaidMessage | RequestLLMLabelsMessage | DisableLLMLabelsMessage - | SetupLLMMessage; + | SetupLLMMessage + | StartAnimationMessage + | StopAnimationMessage + | SwitchPathMessage; export interface FlowchartViewContext { isPanel: boolean; @@ -262,6 +280,33 @@ export abstract class BaseFlowchartProvider { } break; } + + case "startAnimation": { + console.log("Visor Animation: startAnimation received"); + const webview = this.getWebview(); + if (webview) { + webview.postMessage({ command: "startAnimation", payload: {} }); + } + break; + } + + case "stopAnimation": { + console.log("Visor Animation: stopAnimation received"); + const webview = this.getWebview(); + if (webview) { + webview.postMessage({ command: "stopAnimation", payload: {} }); + } + break; + } + + case "switchPath": { + console.log("Visor Animation: switchPath received", message.payload.pathIndex); + const webview = this.getWebview(); + if (webview) { + webview.postMessage({ command: "switchPath", payload: { pathIndex: message.payload.pathIndex } }); + } + break; + } } } @@ -476,6 +521,13 @@ export abstract class BaseFlowchartProvider { } this._isUpdating = true; + + // Stop any running animation when updating view + const animationWebview = this.getWebview(); + if (animationWebview) { + animationWebview.postMessage({ command: "stopAnimation", payload: {} }); + } + this.setWebviewHtml(this.getLoadingHtml("Generating flowchart...")); try { @@ -703,6 +755,41 @@ export abstract class BaseFlowchartProvider { fill: var(--hover-edge-color) !important; font-weight: bold !important; } + + /* Animation styles */ + .animated-current > rect, + .animated-current > polygon, + .animated-current > circle, + .animated-current > path { + stroke: #00ff00 !important; + stroke-width: 5px !important; + filter: drop-shadow(0 0 10px #00ff00); + animation: pulse-glow 1s ease-in-out infinite alternate; + } + + .animated-visited > rect, + .animated-visited > polygon, + .animated-visited > circle, + .animated-visited > path { + fill: rgba(0, 255, 0, 0.2) !important; + stroke: #00aa00 !important; + stroke-width: 2px !important; + } + + .animated-edge { + stroke: #00ff00 !important; + stroke-width: 4px !important; + filter: drop-shadow(0 0 4px #00ff00); + } + + @keyframes pulse-glow { + from { + filter: drop-shadow(0 0 10px #00ff00); + } + to { + filter: drop-shadow(0 0 20px #00ff00); + } + } #mermaid-source { display: none; } @@ -722,6 +809,17 @@ export abstract class BaseFlowchartProvider { const INITIAL_LLM = ${JSON.stringify(llm)}; let isLLMEnabled = false; + // Animation state management + let animationState = { + isAnimating: false, + currentPathIndex: 0, + currentStepIndex: 0, + animationTimer: null, + paths: [], + visitedNodes: new Set(), + visitedEdges: new Set() + }; + function onNodeClick(start, end) { vscode.postMessage({ command: 'highlightCode', @@ -802,6 +900,18 @@ export abstract class BaseFlowchartProvider { setLLMButton(message.payload); break; } + case 'startAnimation': { + startAnimation(); + break; + } + case 'stopAnimation': { + stopAnimation(); + break; + } + case 'switchPath': { + switchAnimationPath(message.payload.pathIndex); + break; + } case 'exportError': // VSC will show the error message break; @@ -975,6 +1085,9 @@ export abstract class BaseFlowchartProvider { const svgElement = document.querySelector('.mermaid svg'); if (svgElement) { setupInteractions(svgElement); + // Initialize animation paths after SVG is ready + animationState.paths = parseAnimationPaths(); + updateAnimationUI(false); } else { console.error('[Visor] SVG element for flowchart not found after initial load!'); } @@ -982,6 +1095,235 @@ export abstract class BaseFlowchartProvider { }); + // Cleanup animation when page unloads + window.addEventListener('beforeunload', () => { + stopAnimation(); + }); + + // Animation functions + function parseAnimationPaths() { + const mermaidSource = document.getElementById('mermaid-source').textContent; + const paths = []; + const pathDescriptions = new Map(); + + const lines = mermaidSource.split('\\n'); + for (const line of lines) { + const pathMatch = line.match(/%% ANIM_PATH:([^:]+):(.+)/); + if (pathMatch) { + const [, pathId, nodeIds] = pathMatch; + paths.push({ + id: pathId, + nodes: nodeIds.split(',').filter(id => id.trim()) + }); + } + + const descMatch = line.match(/%% ANIM_DESC:([^:]+):(.+)/); + if (descMatch) { + const [, pathId, description] = descMatch; + pathDescriptions.set(pathId, description); + } + } + + // Add descriptions to paths + return paths.map(path => ({ + ...path, + description: pathDescriptions.get(path.id) || \`Path \${path.id}\` + })); + } + + function startAnimation() { + if (animationState.isAnimating) { + stopAnimation(); + return; + } + + // Parse paths from mermaid source + animationState.paths = parseAnimationPaths(); + + if (animationState.paths.length === 0) { + console.warn('No animation paths found'); + return; + } + + // Reset state + animationState.currentStepIndex = 0; + animationState.visitedNodes.clear(); + animationState.visitedEdges.clear(); + + // Clear any existing animation classes + clearAnimationClasses(); + + // Update UI + updateAnimationUI(true); + + // Start animation loop + animationState.isAnimating = true; + animationState.animationTimer = setInterval(animateStep, 800); + + console.log('Animation started with', animationState.paths.length, 'paths'); + } + + function stopAnimation() { + if (!animationState.isAnimating) { + return; + } + + animationState.isAnimating = false; + + if (animationState.animationTimer) { + clearInterval(animationState.animationTimer); + animationState.animationTimer = null; + } + + // Clear animation classes + clearAnimationClasses(); + + // Update UI + updateAnimationUI(false); + + console.log('Animation stopped'); + } + + function switchAnimationPath(pathIndex) { + if (pathIndex < 0 || pathIndex >= animationState.paths.length) { + console.warn('Invalid path index:', pathIndex); + return; + } + + animationState.currentPathIndex = pathIndex; + animationState.currentStepIndex = 0; + animationState.visitedNodes.clear(); + animationState.visitedEdges.clear(); + + // Clear animation classes + clearAnimationClasses(); + + // Update path selector button + updatePathSelector(); + + console.log('Switched to path', pathIndex); + } + + function animateStep() { + if (!animationState.isAnimating || animationState.paths.length === 0) { + return; + } + + const currentPath = animationState.paths[animationState.currentPathIndex]; + const currentNodeId = currentPath.nodes[animationState.currentStepIndex]; + + if (!currentNodeId) { + // End of current path, restart + animationState.currentStepIndex = 0; + animationState.visitedNodes.clear(); + animationState.visitedEdges.clear(); + clearAnimationClasses(); + return; + } + + // Highlight current node + highlightCurrentNode(currentNodeId); + + // Mark as visited + animationState.visitedNodes.add(currentNodeId); + + // Highlight edge if not the first node + if (animationState.currentStepIndex > 0) { + const prevNodeId = currentPath.nodes[animationState.currentStepIndex - 1]; + highlightEdge(prevNodeId, currentNodeId); + } + + // Move to next step + animationState.currentStepIndex++; + } + + function highlightCurrentNode(nodeId) { + // Remove previous current highlight + document.querySelectorAll('.animated-current').forEach(el => { + el.classList.remove('animated-current'); + }); + + // Add current highlight + const nodeElement = document.getElementById(nodeId); + if (nodeElement) { + nodeElement.classList.add('animated-current'); + } + + // Add visited highlight to all previously visited nodes + animationState.visitedNodes.forEach(visitedNodeId => { + if (visitedNodeId !== nodeId) { + const visitedElement = document.getElementById(visitedNodeId); + if (visitedElement) { + visitedElement.classList.add('animated-visited'); + } + } + }); + } + + function highlightEdge(fromNodeId, toNodeId) { + const edgeId = \`\${fromNodeId}_\${toNodeId}\`; + animationState.visitedEdges.add(edgeId); + + // Try to find the edge element + const edgePatterns = [ + \`[id*="L_\${fromNodeId}_\${toNodeId}_"]\`, + \`[id*="\${fromNodeId}_\${toNodeId}"]\`, + \`[id*="edge_\${fromNodeId}_\${toNodeId}"]\` + ]; + + for (const pattern of edgePatterns) { + const edgeElement = document.querySelector(pattern); + if (edgeElement) { + edgeElement.classList.add('animated-edge'); + break; + } + } + } + + function clearAnimationClasses() { + document.querySelectorAll('.animated-current, .animated-visited, .animated-edge').forEach(el => { + el.classList.remove('animated-current', 'animated-visited', 'animated-edge'); + }); + } + + function updateAnimationUI(isAnimating) { + const animateToggle = document.getElementById('animate-toggle'); + if (animateToggle) { + if (isAnimating) { + animateToggle.textContent = '⏹️ Stop'; + animateToggle.classList.add('animating'); + animateToggle.title = 'Stop animation'; + } else { + animateToggle.textContent = '🎬 Animate'; + animateToggle.classList.remove('animating'); + animateToggle.title = 'Start animation'; + } + } + + // Show/hide path selector if multiple paths exist + const pathSelector = document.getElementById('path-selector'); + if (pathSelector) { + if (animationState.paths.length > 1) { + pathSelector.style.display = 'inline-block'; + updatePathSelector(); + } else { + pathSelector.style.display = 'none'; + } + } + } + + function updatePathSelector() { + const pathSelector = document.getElementById('path-selector'); + if (pathSelector && animationState.paths.length > 0) { + const currentPath = animationState.paths[animationState.currentPathIndex]; + const shortDesc = currentPath.description.length > 15 + ? currentPath.description.substring(0, 15) + '...' + : currentPath.description; + pathSelector.textContent = \`Path \${animationState.currentPathIndex + 1}\`; + pathSelector.title = currentPath.description; + } + } + function setupButtonHandlers() { const copyBtn = document.getElementById('copy-mermaid'); if (copyBtn) { @@ -1003,6 +1345,26 @@ export abstract class BaseFlowchartProvider { }); } + // Animation button handlers + const animateToggle = document.getElementById('animate-toggle'); + if (animateToggle) { + animateToggle.addEventListener('click', () => { + if (animationState.isAnimating) { + vscode.postMessage({ command: 'stopAnimation', payload: {} }); + } else { + vscode.postMessage({ command: 'startAnimation', payload: {} }); + } + }); + } + + const pathSelector = document.getElementById('path-selector'); + if (pathSelector) { + pathSelector.addEventListener('click', () => { + const nextPathIndex = (animationState.currentPathIndex + 1) % animationState.paths.length; + vscode.postMessage({ command: 'switchPath', payload: { pathIndex: nextPathIndex } }); + }); + } + // Complexity toggle functionality const complexityToggle = document.getElementById('complexity-toggle'); const complexityPanel = document.getElementById('complexity-panel'); @@ -1200,6 +1562,32 @@ export abstract class BaseFlowchartProvider { } #llm-toggle[disabled] { opacity: 0.6; cursor: default; } + /* Animation controls */ + #animate-toggle { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: 1px solid var(--vscode-button-border, transparent); + padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 11px; + } + #animate-toggle:hover { + background-color: var(--vscode-button-hoverBackground); + } + #animate-toggle.animating { + background-color: #00ff00; + color: #000000; + } + + #path-selector { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: 1px solid var(--vscode-button-border, transparent); + padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 11px; + margin-left: 5px; + } + #path-selector:hover { + background-color: var(--vscode-button-hoverBackground); + } + /* Container for complexity panel and toggle button, positioned bottom-left */ #complexity-container { position: fixed; bottom: 10px; left: 10px; z-index: 1001; @@ -1254,6 +1642,8 @@ export abstract class BaseFlowchartProvider { + + @@ -1265,6 +1655,8 @@ export abstract class BaseFlowchartProvider { + + ${ @@ -1348,6 +1740,12 @@ export abstract class BaseFlowchartProvider { this._currentFunctionRange = undefined; this._eventListenersSetup = false; + // Clean up animation state + const disposeWebview = this.getWebview(); + if (disposeWebview) { + disposeWebview.postMessage({ command: "stopAnimation", payload: {} }); + } + while (this._disposables.length) { const x = this._disposables.pop(); if (x) {