From 83d8846b5ee519eb3440ab369b42182aa2f36215 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 19 Jan 2026 04:09:33 -0600 Subject: [PATCH] feat(flowchart): extract FlowchartDecisionGraph component Extract the decision graph visualization into a standalone component. Renders a flowchart-style diamond with option buttons, showing the decision point visually with connecting lines to choices. Features: - Diamond shape for decision question - Option buttons arranged around the diamond - SVG connecting lines between diamond and options - Wrong answer feedback with shake animation - Dark mode support Co-Authored-By: Claude Opus 4.5 --- .../flowchart/FlowchartDecisionGraph.tsx | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 apps/web/src/components/flowchart/FlowchartDecisionGraph.tsx diff --git a/apps/web/src/components/flowchart/FlowchartDecisionGraph.tsx b/apps/web/src/components/flowchart/FlowchartDecisionGraph.tsx new file mode 100644 index 00000000..50c8170b --- /dev/null +++ b/apps/web/src/components/flowchart/FlowchartDecisionGraph.tsx @@ -0,0 +1,510 @@ +'use client' + +import { useState, useEffect } from 'react' +import useMeasure from 'react-use-measure' +import { css, cx } from '../../../styled-system/css' + +interface DecisionOption { + label: string + value: string + /** Label shown on the edge path (e.g., "Yes", "No", "Undo +") */ + pathLabel?: string + /** Where this option leads (next node title) */ + leadsTo?: string +} + +interface FlowchartDecisionGraphProps { + /** Title of the current decision node */ + nodeTitle: string + /** Body content of the decision node (the actual question) */ + nodeBody: string[] + /** The available options */ + options: DecisionOption[] + /** Called when user selects an option */ + onSelect: (value: string) => void + /** Wrong answer for shake animation feedback */ + wrongAnswer?: string + /** Correct answer to highlight */ + correctAnswer?: string + /** Whether to disable interactions during feedback */ + disabled?: boolean + /** Whether there's a previous node to connect from */ + hasPreviousNode?: boolean +} + +// Layout constants +const BUTTON_WIDTH = 100 +const BUTTON_HEIGHT = 36 +const BUTTON_GAP = 20 +const EDGE_LENGTH = 40 +const LABEL_OFFSET_Y = -8 +const MIN_DIAMOND_SIZE = 44 +const DIAMOND_PADDING = 8 // Padding around text inside diamond +const INCOMING_EDGE_LENGTH = 24 // Length of edge from previous node + +/** + * Flowchart-style decision visualization using pure SVG with foreignObject for buttons. + * Layout: [Option A] ←─ ◇ ─→ [Option B] + */ +export function FlowchartDecisionGraph({ + nodeTitle, + nodeBody, + options, + onSelect, + wrongAnswer, + correctAnswer, + disabled = false, + hasPreviousNode = false, +}: FlowchartDecisionGraphProps) { + const [isShaking, setIsShaking] = useState(false) + const [measureRef, bounds] = useMeasure() + + // Shake animation on wrong answer + useEffect(() => { + if (wrongAnswer) { + setIsShaking(true) + const timer = setTimeout(() => setIsShaking(false), 500) + return () => clearTimeout(timer) + } + }, [wrongAnswer]) + + const displayLabel = `📍 ${nodeTitle}` + + // Calculate diamond size based on measured text width + // Text can extend slightly beyond inscribed rectangle, so use 0.9 factor + const textWidth = bounds.width || displayLabel.length * 7 // Fallback estimate until measured + const diamondSize = Math.max(MIN_DIAMOND_SIZE, Math.ceil((textWidth + DIAMOND_PADDING) / 0.9)) + const incomingEdgeSpace = hasPreviousNode ? INCOMING_EDGE_LENGTH : 0 + const svgHeight = diamondSize + 20 + incomingEdgeSpace // Vertical padding + incoming edge + + // Calculate SVG width based on number of options + const totalButtonsWidth = options.length * BUTTON_WIDTH + (options.length - 1) * BUTTON_GAP + const svgWidth = totalButtonsWidth + diamondSize + EDGE_LENGTH * 2 + 40 + + // Center point - offset down if there's an incoming edge + const centerX = svgWidth / 2 + const centerY = (svgHeight + incomingEdgeSpace) / 2 + + // Diamond corners (rotated square) + const diamondHalf = diamondSize / 2 + const diamondLeft = centerX - diamondHalf + const diamondRight = centerX + diamondHalf + + // Split options: left half and right half + const leftOptions = options.slice(0, Math.ceil(options.length / 2)) + const rightOptions = options.slice(Math.ceil(options.length / 2)) + + // Calculate button positions + const getLeftButtonX = (idx: number) => { + const totalLeft = leftOptions.length * BUTTON_WIDTH + (leftOptions.length - 1) * BUTTON_GAP + const startX = diamondLeft - EDGE_LENGTH - totalLeft + return startX + idx * (BUTTON_WIDTH + BUTTON_GAP) + } + + const getRightButtonX = (idx: number) => { + const startX = diamondRight + EDGE_LENGTH + return startX + idx * (BUTTON_WIDTH + BUTTON_GAP) + } + + const buttonY = centerY - BUTTON_HEIGHT / 2 + + return ( +
+ {/* Hidden span for measuring text width */} + + {displayLabel} + + + {/* Question content from node body */} + {nodeBody.length > 0 && ( +
+ {nodeBody.map((line, idx) => ( +

+ {line} +

+ ))} +
+ )} + + + {/* Layer 1: Edge lines (bottom layer) */} + + {/* Incoming edge from previous node */} + {hasPreviousNode && ( + + + {/* Arrow pointing down to diamond */} + + + )} + + {/* Left side edges */} + {leftOptions.map((option, idx) => { + const buttonX = getLeftButtonX(idx) + const edgeStartX = buttonX + BUTTON_WIDTH + const edgeEndX = diamondLeft + + return ( + + + + {option.pathLabel && ( + + {option.pathLabel} + + )} + + ) + })} + + {/* Right side edges */} + {rightOptions.map((option, idx) => { + const actualIdx = leftOptions.length + idx + const buttonX = getRightButtonX(idx) + const edgeStartX = diamondRight + const edgeEndX = buttonX + + return ( + + + + {option.pathLabel && ( + + {option.pathLabel} + + )} + + ) + })} + + + {/* Layer 2: Diamond decision node (middle layer, on top of edges) */} + + + {/* Title label inside diamond using foreignObject */} + +
+ {displayLabel} +
+
+
+ + {/* Layer 3: Buttons (top layer, interactive) */} + + {/* Left side buttons */} + {leftOptions.map((option, idx) => { + const buttonX = getLeftButtonX(idx) + const isCorrect = correctAnswer === option.value + const isWrong = wrongAnswer === option.value + + return ( + + + + ) + })} + + {/* Right side buttons */} + {rightOptions.map((option, idx) => { + const actualIdx = leftOptions.length + idx + const buttonX = getRightButtonX(idx) + const isCorrect = correctAnswer === option.value + const isWrong = wrongAnswer === option.value + + return ( + + + + ) + })} + +
+ + {/* Shake animation styles */} +