fix(flowcharts): improve edge highlighting with BFS traversal for phase boundaries

- Rewrite DebugMermaidDiagram edge matching to use BFS graph traversal
- Build graph from SVG edges (L_FROM_TO_INDEX format) for path finding
- Handle phase boundary disconnections with bidirectional BFS:
  - Forward BFS finds all nodes reachable from start
  - Backward BFS finds all nodes that can reach end
  - Combines both to highlight intermediate nodes across phase gaps
- Remove complex pattern matching in favor of graph-based approach
- Auto-compute edge IDs as {nodeId}_{optionValue} in loader.ts
- Add computeEdgeId() helper to schema.ts for consistent edge ID generation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-23 10:28:02 -06:00
parent 2f82bc28ec
commit fc15334aec
3 changed files with 284 additions and 109 deletions

View File

@ -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<string>()
const highlightedEdgeIndices = new Set<number>()
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<string, Array<{ to: string; edgeId: string; element: Element }>>()
const allSvgNodeIds = new Set<string>()
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<string>([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<string>([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<string, Array<{ from: string; element: Element }>>()
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<string>([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<string>()
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<Element>()
const focusedOutgoingEdges = new Set<Element>()
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<Element>()
// 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')
})
}
}

View File

@ -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<string, ExecutableNode> = {}
@ -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

View File

@ -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 }
}