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 ( +
+ {line} +
+ ))} +