diff --git a/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx b/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx index cf5cb77f..940ab728 100644 --- a/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx +++ b/apps/web/src/components/flowchart/DebugMermaidDiagram.tsx @@ -96,6 +96,7 @@ export function DebugMermaidDiagram({ const hasPathHighlight = highlightedPath.length > 0 const hasNodeHighlight = highlightedNodeId && highlightedNodeId !== currentNodeId + if (hasPathHighlight || hasNodeHighlight) { // Dim all nodes when we have any highlighting const allNodes = svgElement.querySelectorAll('[id^="flowchart-"]') @@ -104,7 +105,8 @@ export function DebugMermaidDiagram({ }) // Dim all edge paths (the lines) - use multiple selectors for robustness - const allEdgePathElements = svgElement.querySelectorAll('.edgePath path, .edgePaths path') + // Mermaid v11+ uses .flowchart-link, older versions use .edgePath path + const allEdgePathElements = svgElement.querySelectorAll('.flowchart-link, .edgePath path, .edgePaths path') allEdgePathElements.forEach((edge) => { ;(edge as SVGElement).style.opacity = '0.2' ;(edge as SVGElement).style.stroke = '#9ca3af' // gray-400 @@ -117,110 +119,241 @@ export function DebugMermaidDiagram({ }) // Get edge containers for matching - const edgePathsContainer = svgElement.querySelector('.edgePaths') - const edgeLabelsContainer = svgElement.querySelector('.edgeLabels') - const edgePathElements = edgePathsContainer ? Array.from(edgePathsContainer.children) : [] - const edgeLabelElements = edgeLabelsContainer ? Array.from(edgeLabelsContainer.children) : [] + // Mermaid v11+ uses .flowchart-link class for edge paths + // inside a container with class .edgePaths (plural) + const allEdgePaths = svgElement.querySelectorAll('.flowchart-link') + const edgePathElements = Array.from(allEdgePaths) + const edgeLabelElements = Array.from(allEdgeLabels) - // Collect edge IDs and indices from snapshots for highlighting - // We support two matching modes: - // 1. ID-based: If edges use `id@-->` syntax, SVG elements have custom IDs - // 2. Index-based: Fallback using parse order (mermaid renders in parse order) - const highlightedEdgeIds = new Set() - const highlightedEdgeIndices = new Set() - let focusedIncomingEdgeId: string | undefined - let focusedIncomingEdgeIndex: number | undefined - let focusedOutgoingEdgeId: string | undefined - let focusedOutgoingEdgeIndex: number | undefined - if (highlightedSnapshots) { + // Collect path nodes for highlighting + const pathNodeSet = new Set(highlightedPath.filter(n => n !== 'initial')) + + /** + * Build a graph from SVG edges for BFS traversal. + * Extracts from/to from L_FROM_TO_INDEX format. + */ + const mermaidGraph = new Map>() + const allSvgNodeIds = new Set() + + for (const edge of edgePathElements) { + const svgEdgeId = edge.id + if (!svgEdgeId) continue + + // Parse L_FROM_TO_INDEX format + if (svgEdgeId.startsWith('L_')) { + const withoutPrefix = svgEdgeId.slice(2) // Remove 'L_' + const lastUnderscore = withoutPrefix.lastIndexOf('_') + if (lastUnderscore > 0) { + const fromTo = withoutPrefix.slice(0, lastUnderscore) // Remove '_INDEX' + + // Find all node elements to know valid node IDs + const allNodeElements = svgElement.querySelectorAll('[id^="flowchart-"]') + const nodeIds = new Set( + Array.from(allNodeElements) + .map(e => e.id.match(/flowchart-([^-]+)-/)?.[1]) + .filter(Boolean) as string[] + ) + + // Try to split FROM_TO by finding a valid node ID prefix + for (const nodeId of nodeIds) { + if (fromTo.startsWith(`${nodeId}_`)) { + const toNode = fromTo.slice(nodeId.length + 1) + if (nodeIds.has(toNode)) { + allSvgNodeIds.add(nodeId) + allSvgNodeIds.add(toNode) + + if (!mermaidGraph.has(nodeId)) { + mermaidGraph.set(nodeId, []) + } + mermaidGraph.get(nodeId)!.push({ to: toNode, edgeId: svgEdgeId, element: edge }) + break + } + } + } + } + } + } + + /** + * BFS to find path from `start` to `end` through mermaid graph. + * Returns edges and intermediate nodes on the path. + * + * If direct path not found (due to phase boundaries), falls back to + * finding all nodes reachable from start + all nodes that can reach end. + */ + const findPathBFS = (start: string, end: string): { edges: Element[]; intermediateNodes: string[] } => { + if (start === end) return { edges: [], intermediateNodes: [] } + + // Try direct BFS first + const queue: Array<{ node: string; path: Array<{ to: string; edgeId: string; element: Element }> }> = [ + { node: start, path: [] } + ] + const visited = new Set([start]) + + while (queue.length > 0) { + const { node, path } = queue.shift()! + const neighbors = mermaidGraph.get(node) || [] + + for (const neighbor of neighbors) { + if (neighbor.to === end) { + // Found the path! + const fullPath = [...path, neighbor] + return { + edges: fullPath.map(p => p.element), + intermediateNodes: fullPath.slice(0, -1).map(p => p.to) + } + } + + if (!visited.has(neighbor.to)) { + visited.add(neighbor.to) + queue.push({ node: neighbor.to, path: [...path, neighbor] }) + } + } + } + + // Direct path not found - likely a phase boundary + // Find all reachable from start (forward BFS) + const reachableFromStart = new Set([start]) + const edgesFromStart: Element[] = [] + let frontier = [start] + while (frontier.length > 0) { + const node = frontier.shift()! + for (const neighbor of mermaidGraph.get(node) || []) { + if (!reachableFromStart.has(neighbor.to)) { + reachableFromStart.add(neighbor.to) + edgesFromStart.push(neighbor.element) + frontier.push(neighbor.to) + } + } + } + + // Build reverse graph for backward BFS + const reverseGraph = new Map>() + for (const [from, neighbors] of mermaidGraph) { + for (const n of neighbors) { + if (!reverseGraph.has(n.to)) reverseGraph.set(n.to, []) + reverseGraph.get(n.to)!.push({ from, element: n.element }) + } + } + + // Find all that can reach end (backward BFS) + const canReachEnd = new Set([end]) + const edgesToEnd: Element[] = [] + frontier = [end] + while (frontier.length > 0) { + const node = frontier.shift()! + for (const neighbor of reverseGraph.get(node) || []) { + if (!canReachEnd.has(neighbor.from)) { + canReachEnd.add(neighbor.from) + edgesToEnd.push(neighbor.element) + frontier.push(neighbor.from) + } + } + } + + // Combine: nodes reachable from start + nodes that can reach end + // minus start and end themselves + const intermediateNodes = new Set() + for (const node of reachableFromStart) { + if (node !== start && node !== end) intermediateNodes.add(node) + } + for (const node of canReachEnd) { + if (node !== start && node !== end) intermediateNodes.add(node) + } + + return { + edges: [...edgesFromStart, ...edgesToEnd], + intermediateNodes: [...intermediateNodes] + } + } + + + // Find focused node's neighbors in the path for edge highlighting + let focusedPrevNode: string | undefined + let focusedNextNode: string | undefined + + if (highlightedSnapshots && highlightedNodeId) { for (let i = 0; i < highlightedSnapshots.length; i++) { const snapshot = highlightedSnapshots[i] - - // Collect edge ID if available - if (snapshot.edgeId) { - highlightedEdgeIds.add(snapshot.edgeId) + // If this snapshot's next node is the focused node, this is the predecessor + if (snapshot.nextNodeId === highlightedNodeId && snapshot.nodeId !== 'initial') { + focusedPrevNode = snapshot.nodeId } - // Also collect index for fallback - if (snapshot.edgeIndex !== undefined) { - highlightedEdgeIndices.add(snapshot.edgeIndex) - } - - // Track edges for focused node highlighting - if (highlightedNodeId) { - // If this snapshot's next node is the focused node, this is the incoming edge - if (snapshot.nextNodeId === highlightedNodeId) { - focusedIncomingEdgeId = snapshot.edgeId - focusedIncomingEdgeIndex = snapshot.edgeIndex - } - // If this snapshot IS the focused node, this is the outgoing edge - if (snapshot.nodeId === highlightedNodeId) { - focusedOutgoingEdgeId = snapshot.edgeId - focusedOutgoingEdgeIndex = snapshot.edgeIndex - } + // If this snapshot IS the focused node, its next is the successor + if (snapshot.nodeId === highlightedNodeId) { + focusedNextNode = snapshot.nextNodeId } } } - // Helper to check if an edge element should be highlighted - const shouldHighlightEdge = (edgeGroup: Element, index: number): boolean => { - // Try ID-based matching first (for `id@-->` syntax) - if (edgeGroup.id && highlightedEdgeIds.has(edgeGroup.id)) { - return true - } - // Fall back to index-based matching - return highlightedEdgeIndices.has(index) + // Find edges leading to/from focused node (using BFS to handle intermediates) + const focusedIncomingEdges = new Set() + const focusedOutgoingEdges = new Set() + + if (focusedPrevNode && highlightedNodeId) { + const { edges } = findPathBFS(focusedPrevNode, highlightedNodeId) + edges.forEach(e => focusedIncomingEdges.add(e)) + } + if (highlightedNodeId && focusedNextNode) { + const { edges } = findPathBFS(highlightedNodeId, focusedNextNode) + edges.forEach(e => focusedOutgoingEdges.add(e)) } - // Helper to check if an edge is the focused incoming edge - const isFocusedIncoming = (edgeGroup: Element, index: number): boolean => { - if (focusedIncomingEdgeId && edgeGroup.id === focusedIncomingEdgeId) return true - return focusedIncomingEdgeIndex !== undefined && index === focusedIncomingEdgeIndex - } - - // Helper to check if an edge is the focused outgoing edge - const isFocusedOutgoing = (edgeGroup: Element, index: number): boolean => { - if (focusedOutgoingEdgeId && edgeGroup.id === focusedOutgoingEdgeId) return true - return focusedOutgoingEdgeIndex !== undefined && index === focusedOutgoingEdgeIndex - } - - // Highlight path nodes (light cyan background) + // Highlight path nodes and edges if (hasPathHighlight) { - for (const nodeId of highlightedPath) { - if (nodeId === 'initial') continue // Skip pseudo-node + // Start with path nodes from simulation + const nodesToHighlight = new Set(highlightedPath.filter(n => n !== 'initial')) + const edgesToHighlight = new Set() + + // For each consecutive pair of path nodes, find the path through mermaid graph + const pathArray = highlightedPath.filter(n => n !== 'initial') + for (let i = 0; i < pathArray.length - 1; i++) { + const from = pathArray[i] + const to = pathArray[i + 1] + + const { edges, intermediateNodes } = findPathBFS(from, to) + + // Add intermediate nodes + for (const node of intermediateNodes) { + nodesToHighlight.add(node) + } + + // Add edges + for (const edge of edges) { + edgesToHighlight.add(edge) + } + } + + + // Highlight all nodes (path + intermediate) + for (const nodeId of nodesToHighlight) { const nodeElement = svgElement.querySelector(`[id*="flowchart-${nodeId}-"]`) if (nodeElement) { const svgNode = nodeElement as SVGElement svgNode.style.opacity = '1' - // Add light cyan border for path nodes - const shape = nodeElement.querySelector('rect, polygon, circle, ellipse, path') + // Add light cyan border + const shape = nodeElement.querySelector('rect, polygon, circle, ellipse, path') as SVGElement | null if (shape) { - shape.setAttribute('stroke', '#06b6d4') // cyan-500 - shape.setAttribute('stroke-width', '2') + shape.style.stroke = '#06b6d4' // cyan-500 + shape.style.strokeWidth = '3px' + shape.setAttribute('stroke', '#06b6d4') + shape.setAttribute('stroke-width', '3') shape.setAttribute('vector-effect', 'non-scaling-stroke') } } } - // Highlight edges using ID (preferred) or index (fallback) - edgePathElements.forEach((edgeGroup, index) => { - if (shouldHighlightEdge(edgeGroup, index)) { - // Find the path element inside this group - const pathElement = edgeGroup.querySelector('path') || edgeGroup - const svgEdge = pathElement as SVGElement - svgEdge.style.opacity = '1' - svgEdge.style.stroke = '#06b6d4' // cyan-500 - svgEdge.style.strokeWidth = '3px' - svgEdge.setAttribute('stroke', '#06b6d4') - svgEdge.setAttribute('stroke-width', '3') - svgEdge.setAttribute('vector-effect', 'non-scaling-stroke') - - // Also highlight the corresponding label - if (edgeLabelElements[index]) { - ;(edgeLabelElements[index] as SVGElement).style.opacity = '1' - } - } + // Highlight edges + edgesToHighlight.forEach((edgeElement) => { + const svgEdge = edgeElement as SVGElement + svgEdge.style.opacity = '1' + svgEdge.style.stroke = '#06b6d4' // cyan-500 + svgEdge.style.strokeWidth = '3px' + svgEdge.setAttribute('stroke', '#06b6d4') + svgEdge.setAttribute('stroke-width', '3') + svgEdge.setAttribute('vector-effect', 'non-scaling-stroke') }) } @@ -232,34 +365,32 @@ export function DebugMermaidDiagram({ svgNode.style.opacity = '1' // Add thick cyan border with non-scaling stroke - const shape = nodeElement.querySelector('rect, polygon, circle, ellipse, path') + const shape = nodeElement.querySelector('rect, polygon, circle, ellipse, path') as SVGElement | null if (shape) { - shape.setAttribute('stroke', '#0891b2') // cyan-600 + shape.style.stroke = '#0891b2' // cyan-600 + shape.style.strokeWidth = '5px' + shape.setAttribute('stroke', '#0891b2') shape.setAttribute('stroke-width', '5') shape.setAttribute('vector-effect', 'non-scaling-stroke') } } // Highlight edges to/from focused node more strongly - edgePathElements.forEach((edgeGroup, index) => { - if (isFocusedIncoming(edgeGroup, index)) { - const pathElement = edgeGroup.querySelector('path') || edgeGroup - const svgEdge = pathElement as SVGElement - svgEdge.style.opacity = '1' - svgEdge.style.stroke = '#0891b2' // cyan-600 - svgEdge.style.strokeWidth = '5px' - svgEdge.setAttribute('stroke', '#0891b2') - svgEdge.setAttribute('stroke-width', '5') - } - if (isFocusedOutgoing(edgeGroup, index)) { - const pathElement = edgeGroup.querySelector('path') || edgeGroup - const svgEdge = pathElement as SVGElement - svgEdge.style.opacity = '1' - svgEdge.style.stroke = '#22d3ee' // cyan-400 - svgEdge.style.strokeWidth = '4px' - svgEdge.setAttribute('stroke', '#22d3ee') - svgEdge.setAttribute('stroke-width', '4') - } + focusedIncomingEdges.forEach((edgeElement) => { + const svgEdge = edgeElement as SVGElement + svgEdge.style.opacity = '1' + svgEdge.style.stroke = '#0891b2' // cyan-600 + svgEdge.style.strokeWidth = '5px' + svgEdge.setAttribute('stroke', '#0891b2') + svgEdge.setAttribute('stroke-width', '5') + }) + focusedOutgoingEdges.forEach((edgeElement) => { + const svgEdge = edgeElement as SVGElement + svgEdge.style.opacity = '1' + svgEdge.style.stroke = '#22d3ee' // cyan-400 + svgEdge.style.strokeWidth = '4px' + svgEdge.setAttribute('stroke', '#22d3ee') + svgEdge.setAttribute('stroke-width', '4') }) } } diff --git a/apps/web/src/lib/flowcharts/loader.ts b/apps/web/src/lib/flowcharts/loader.ts index 804e487f..cd5e60f0 100644 --- a/apps/web/src/lib/flowcharts/loader.ts +++ b/apps/web/src/lib/flowcharts/loader.ts @@ -56,6 +56,17 @@ import { parseMermaidFile, parseNodeContent } from './parser' import { evaluate, type EvalContext } from './evaluator' import { formatProblemDisplay, interpolateTemplate } from './formatting' +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Escape special regex characters in a string. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + // ============================================================================= // Re-exports for backwards compatibility // ============================================================================= @@ -140,6 +151,9 @@ export async function loadFlowchart( // Parse the Mermaid file const mermaid = parseMermaidFile(mermaidContent) + // Track edge ID infills so we can inject them into the raw mermaid string + const edgeIdInfills: Array<{ from: string; to: string; label?: string; edgeId: string }> = [] + // Infill missing edge IDs for decision edges // For each decision node, if the edge doesn't have an explicit ID (starts with "edge_"), // assign the computed ID based on {nodeId}_{optionValue} @@ -155,10 +169,36 @@ export async function loadFlowchart( if (edge && edge.id.startsWith('edge_')) { // Edge exists but has auto-generated ID - replace with computed ID edge.id = expectedEdgeId + // Track for injection into raw mermaid + edgeIdInfills.push({ + from: nodeId, + to: option.next, + label: edge.label, + edgeId: expectedEdgeId, + }) } } } + // Inject edge IDs into the raw mermaid string so SVG elements will have them + let modifiedMermaid = mermaidContent + for (const infill of edgeIdInfills) { + // Build regex to match this specific edge + // Pattern: FROM -->|"LABEL"| TO or FROM --> TO + const labelPart = infill.label + ? `\\s*\\|"${escapeRegex(infill.label)}"\\|\\s*` + : '\\s*' + const edgeRegex = new RegExp( + `(${escapeRegex(infill.from)})\\s+-->${labelPart}(${escapeRegex(infill.to)})`, + 'g' + ) + // Replace with: FROM EDGEID@-->|"LABEL"| TO + const replacement = infill.label + ? `$1 ${infill.edgeId}@-->|"${infill.label}"| $2` + : `$1 ${infill.edgeId}@--> $2` + modifiedMermaid = modifiedMermaid.replace(edgeRegex, replacement) + } + // Build executable nodes by merging definition with parsed content const nodes: Record = {} @@ -220,7 +260,7 @@ export async function loadFlowchart( return { definition, mermaid, - rawMermaid: mermaidContent, + rawMermaid: modifiedMermaid, nodes, } } @@ -620,7 +660,6 @@ function getNextNodeForSimulation( const edgeById = flowchart.mermaid.edges.find((e) => e.id === edgeId) if (edgeById) return edgeById // If specified edgeId doesn't exist, fall through to from/to matching - console.warn(`Edge ID "${edgeId}" not found, falling back to from/to matching`) } // Priority 2+: Match by from/to diff --git a/apps/web/src/lib/flowcharts/parser.ts b/apps/web/src/lib/flowcharts/parser.ts index e8cd781e..8e17f899 100644 --- a/apps/web/src/lib/flowcharts/parser.ts +++ b/apps/web/src/lib/flowcharts/parser.ts @@ -344,20 +344,24 @@ export function parseMermaidFile(content: string): ParsedMermaid { // Parse edges // Supports: // - Basic: ID1 --> ID2 - // - With label: ID1 -->|"label"| ID2 + // - With quoted label: ID1 -->|"label"| ID2 + // - With unquoted label: ID1 -->|label| ID2 // - With edge ID (mermaid v11.6+): ID1 edgeId@--> ID2 - // - With edge ID and label: ID1 edgeId@-->|"label"| ID2 + // - With edge ID and label: ID1 edgeId@-->|"label"| ID2 or ID1 edgeId@-->|label| ID2 // - Chained: ID1 --> ID2 --> ID3 // // Edge ID syntax: The ID comes before @ and before the arrow - const edgePattern = /(\w+)\s+(?:(\w+)@)?-->\s*(?:\|"([^"]+)"\|\s*)?(\w+)/g + // Label can be quoted ("label") or unquoted (label) + const edgePattern = /(\w+)\s+(?:(\w+)@)?-->\s*(?:\|"?([^"|]+)"?\|\s*)?(\w+)/g let edgeMatch let edgeIndex = 0 + console.log('[PARSER] Parsing edges from mermaid content...') while ((edgeMatch = edgePattern.exec(cleanContent)) !== null) { - const [, from, edgeId, label, to] = edgeMatch + const [fullMatch, from, edgeId, label, to] = edgeMatch // Skip phase-to-phase connections and style definitions if (from.startsWith('PHASE') || from === 'style') continue + console.log(`[PARSER] Edge ${edgeIndex}: from=${from}, to=${to}, edgeId=${edgeId || 'auto'}, label=${label || 'none'}, fullMatch="${fullMatch}"`) edges.push({ from, to, @@ -368,6 +372,7 @@ export function parseMermaidFile(content: string): ParsedMermaid { }) edgeIndex++ } + console.log(`[PARSER] Total edges parsed: ${edges.length}`) return { nodes, edges, phases } }