Add constraint-guided example generation with variety improvements
- Add constraint parser for flowchart problem generation - Add interestingness constraints to prevent trivially easy problems: - fraction-add-sub: notIdentical, meaningfulSubtraction, nonTrivialNumerators - subtraction-regrouping: meaningfulDifference, notTooEasy - Fix deterministic example selection with randomized picking from smaller half of candidates sorted by numeric sum - Add deduplication to prevent duplicate examples from biasing selection - Add TeacherConfigPanel for flowchart practice configuration - Add InteractiveDice component for random problem generation UI - Add MathML type declarations - Various UI improvements to flowchart walker components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { css } from '@styled/css'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||
import { InteractiveDice } from '@/components/ui/InteractiveDice'
|
||||
import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { extractConfigFields } from '../utils/extractConfigFields'
|
||||
@@ -18,199 +17,6 @@ import { WorksheetPreview } from './WorksheetPreview'
|
||||
import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner'
|
||||
import { WorksheetPreviewProvider } from './worksheet-preview/WorksheetPreviewContext'
|
||||
|
||||
// Dot patterns for each dice face (positions as fractions of face size)
|
||||
const DICE_DOT_PATTERNS: Record<number, Array<[number, number]>> = {
|
||||
1: [[0.5, 0.5]],
|
||||
2: [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
3: [
|
||||
[0.25, 0.25],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
4: [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.25],
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
5: [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.25],
|
||||
[0.5, 0.5],
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
6: [
|
||||
[0.25, 0.2],
|
||||
[0.25, 0.5],
|
||||
[0.25, 0.8],
|
||||
[0.75, 0.2],
|
||||
[0.75, 0.5],
|
||||
[0.75, 0.8],
|
||||
],
|
||||
}
|
||||
|
||||
// Rotation needed to show each face
|
||||
// Standard dice: 1 opposite 6, 2 opposite 5, 3 opposite 4
|
||||
const DICE_FACE_ROTATIONS: Record<number, { rotateX: number; rotateY: number }> = {
|
||||
1: { rotateX: 0, rotateY: 0 }, // front
|
||||
2: { rotateX: 0, rotateY: -90 }, // right
|
||||
3: { rotateX: -90, rotateY: 0 }, // top
|
||||
4: { rotateX: 90, rotateY: 0 }, // bottom
|
||||
5: { rotateX: 0, rotateY: 90 }, // left
|
||||
6: { rotateX: 0, rotateY: 180 }, // back
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D Dice Icon using react-spring for smooth animations
|
||||
*
|
||||
* Creates a cube with 6 faces, each showing the appropriate dot pattern.
|
||||
* The cube rotates on all 3 axes when rolling, with physics-based easing.
|
||||
*/
|
||||
function DiceIcon({
|
||||
className,
|
||||
rotateX,
|
||||
rotateY,
|
||||
rotateZ,
|
||||
isDark,
|
||||
}: {
|
||||
className?: string
|
||||
rotateX: number
|
||||
rotateY: number
|
||||
rotateZ: number
|
||||
isDark: boolean
|
||||
}) {
|
||||
const size = 22
|
||||
const halfSize = size / 2
|
||||
|
||||
// Animate rotation with react-spring
|
||||
const springProps = useSpring({
|
||||
rotateX,
|
||||
rotateY,
|
||||
rotateZ,
|
||||
config: {
|
||||
tension: 120,
|
||||
friction: 14,
|
||||
},
|
||||
})
|
||||
|
||||
// Theme-aware colors
|
||||
// Dark mode: lighter indigo with more contrast against dark backgrounds
|
||||
// Light mode: deeper indigo that stands out against light backgrounds
|
||||
const faceBackground = isDark ? '#818cf8' : '#4f46e5' // indigo-400 dark, indigo-600 light
|
||||
const faceBorder = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.5)'
|
||||
const dotColor = isDark ? '#1e1b4b' : 'white' // indigo-950 dots on light bg in dark mode
|
||||
|
||||
// Render dots for a face
|
||||
const renderDots = (face: number) => {
|
||||
const dots = DICE_DOT_PATTERNS[face] || []
|
||||
return dots.map(([x, y], i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
width: '18%',
|
||||
height: '18%',
|
||||
backgroundColor: dotColor,
|
||||
borderRadius: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
// Common face styles - opaque background to prevent artifacts during rotation
|
||||
const faceStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: faceBackground,
|
||||
border: `1.5px solid ${faceBorder}`,
|
||||
borderRadius: 2,
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
perspective: 100,
|
||||
perspectiveOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<animated.div
|
||||
data-dice-cube
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
position: 'relative',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: springProps.rotateX.to(
|
||||
(rx) =>
|
||||
`rotateX(${rx}deg) rotateY(${springProps.rotateY.get()}deg) rotateZ(${springProps.rotateZ.get()}deg)`
|
||||
),
|
||||
}}
|
||||
>
|
||||
{/* Front face (1) */}
|
||||
<div style={{ ...faceStyle, transform: `translateZ(${halfSize}px)` }}>{renderDots(1)}</div>
|
||||
{/* Back face (6) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateY(180deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(6)}
|
||||
</div>
|
||||
{/* Right face (2) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateY(90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(2)}
|
||||
</div>
|
||||
{/* Left face (5) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateY(-90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(5)}
|
||||
</div>
|
||||
{/* Top face (3) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateX(90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(3)}
|
||||
</div>
|
||||
{/* Bottom face (4) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateX(-90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(4)}
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PreviewCenterProps {
|
||||
formState: WorksheetFormState
|
||||
initialPreview?: string[]
|
||||
@@ -242,261 +48,7 @@ export function PreviewCenter({
|
||||
const [isLoadShareModalOpen, setIsLoadShareModalOpen] = useState(false)
|
||||
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
|
||||
const [justCopied, setJustCopied] = useState(false)
|
||||
// Dice rotation state for react-spring animation
|
||||
// We track cumulative spins to add visual flair, then compute final rotation
|
||||
// to land on the correct face derived from the seed
|
||||
const [spinCount, setSpinCount] = useState(0)
|
||||
// Track which face we're showing (for ensuring consecutive rolls differ)
|
||||
const [currentFace, setCurrentFace] = useState(() => ((formState.seed ?? 0) % 5) + 2)
|
||||
|
||||
// Draggable dice state with physics simulation
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [diceOrigin, setDiceOrigin] = useState({ x: 0, y: 0 })
|
||||
const diceButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const diceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const dragStartPos = useRef({ x: 0, y: 0 })
|
||||
|
||||
// Physics state for thrown dice
|
||||
const [isFlying, setIsFlying] = useState(false)
|
||||
const dicePhysics = useRef({
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
scale: 1, // Grows to 3x when flying, shrinks back when settling
|
||||
})
|
||||
const lastPointerPos = useRef({ x: 0, y: 0, time: 0 })
|
||||
// Track velocity samples for smoother flick detection
|
||||
const velocitySamples = useRef<Array<{ vx: number; vy: number; time: number }>>([])
|
||||
const animationFrameRef = useRef<number>()
|
||||
|
||||
// Ref to the portal dice element for direct DOM manipulation during drag/flying (avoids re-renders)
|
||||
const portalDiceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Compute target rotation for the current face (needed by physics simulation)
|
||||
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
}
|
||||
|
||||
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
|
||||
useEffect(() => {
|
||||
if (!isFlying) return
|
||||
|
||||
const BASE_GRAVITY = 0.8 // Base pull toward origin
|
||||
const BASE_FRICTION = 0.94 // Base velocity dampening (slightly less friction for longer flight)
|
||||
const ROTATION_FACTOR = 0.5 // How much velocity affects rotation
|
||||
const STOP_THRESHOLD = 2 // Distance threshold to snap home
|
||||
const VELOCITY_THRESHOLD = 0.5 // Velocity threshold to snap home
|
||||
const CLOSE_RANGE = 40 // Distance at which extra damping kicks in
|
||||
const MAX_SCALE = 3 // Maximum scale when flying
|
||||
const SCALE_GROW_SPEED = 0.2 // How fast to grow (faster)
|
||||
const SCALE_SHRINK_SPEED = 0.06 // How fast to shrink when close (slower for drama)
|
||||
const BOUNCE_DAMPING = 0.7 // How much velocity is retained on bounce (0-1)
|
||||
const DICE_SIZE = 22 // Size of the dice in pixels
|
||||
|
||||
// Calculate initial throw power to adjust gravity (stronger throws = weaker initial gravity)
|
||||
const initialSpeed = Math.sqrt(
|
||||
dicePhysics.current.vx * dicePhysics.current.vx +
|
||||
dicePhysics.current.vy * dicePhysics.current.vy
|
||||
)
|
||||
const throwPower = Math.min(initialSpeed / 20, 1) // 0-1 based on throw strength
|
||||
|
||||
let frameCount = 0
|
||||
|
||||
const animate = () => {
|
||||
const p = dicePhysics.current
|
||||
const el = portalDiceRef.current
|
||||
if (!el) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
|
||||
frameCount++
|
||||
|
||||
// Calculate distance to origin
|
||||
const dist = Math.sqrt(p.x * p.x + p.y * p.y)
|
||||
|
||||
// Gravity ramps up over time (weak at first for strong throws, then strengthens)
|
||||
const gravityRampUp = Math.min(frameCount / 30, 1) // Full gravity after ~0.5s
|
||||
const effectiveGravity = BASE_GRAVITY * (0.3 + 0.7 * gravityRampUp) * (1 - throwPower * 0.5)
|
||||
|
||||
// Apply spring force toward origin (proportional to distance)
|
||||
if (dist > 0) {
|
||||
// Quadratic falloff for more natural feel
|
||||
const pullStrength = effectiveGravity * (dist / 50) ** 1.2
|
||||
p.vx += (-p.x / dist) * pullStrength
|
||||
p.vy += (-p.y / dist) * pullStrength
|
||||
}
|
||||
|
||||
// Apply friction - extra damping when close to prevent oscillation
|
||||
const friction = dist < CLOSE_RANGE ? 0.88 : BASE_FRICTION
|
||||
p.vx *= friction
|
||||
p.vy *= friction
|
||||
|
||||
// Update position
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
|
||||
// Viewport edge bounce - calculate absolute position and check bounds
|
||||
const scaledSize = DICE_SIZE * p.scale
|
||||
const absoluteX = diceOrigin.x + p.x
|
||||
const absoluteY = diceOrigin.y + p.y
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// Left edge bounce
|
||||
if (absoluteX < 0) {
|
||||
p.x = -diceOrigin.x // Position at left edge
|
||||
p.vx = Math.abs(p.vx) * BOUNCE_DAMPING // Reverse and dampen
|
||||
// Add extra spin on bounce
|
||||
p.rotationZ += p.vx * 5
|
||||
}
|
||||
// Right edge bounce
|
||||
if (absoluteX + scaledSize > viewportWidth) {
|
||||
p.x = viewportWidth - diceOrigin.x - scaledSize
|
||||
p.vx = -Math.abs(p.vx) * BOUNCE_DAMPING
|
||||
p.rotationZ -= p.vx * 5
|
||||
}
|
||||
// Top edge bounce
|
||||
if (absoluteY < 0) {
|
||||
p.y = -diceOrigin.y
|
||||
p.vy = Math.abs(p.vy) * BOUNCE_DAMPING
|
||||
p.rotationZ += p.vy * 5
|
||||
}
|
||||
// Bottom edge bounce
|
||||
if (absoluteY + scaledSize > viewportHeight) {
|
||||
p.y = viewportHeight - diceOrigin.y - scaledSize
|
||||
p.vy = -Math.abs(p.vy) * BOUNCE_DAMPING
|
||||
p.rotationZ -= p.vy * 5
|
||||
}
|
||||
|
||||
// Update rotation based on velocity (dice rolls as it moves)
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
|
||||
|
||||
// As dice gets closer to home, gradually lerp rotation toward final face
|
||||
// settleProgress: 0 = far away (full physics rotation), 1 = at home (target rotation)
|
||||
const SETTLE_START_DIST = 150 // Start settling rotation at this distance
|
||||
const settleProgress = Math.max(0, 1 - dist / SETTLE_START_DIST)
|
||||
const settleFactor = settleProgress * settleProgress * settleProgress // Cubic easing for smooth settle
|
||||
|
||||
// Physics rotation (tumbling)
|
||||
const physicsRotationDelta = {
|
||||
x: p.vy * ROTATION_FACTOR * 12,
|
||||
y: -p.vx * ROTATION_FACTOR * 12,
|
||||
z: speed * ROTATION_FACTOR * 3,
|
||||
}
|
||||
|
||||
// Apply physics rotation, but reduced as we settle
|
||||
p.rotationX += physicsRotationDelta.x * (1 - settleFactor)
|
||||
p.rotationY += physicsRotationDelta.y * (1 - settleFactor)
|
||||
p.rotationZ += physicsRotationDelta.z * (1 - settleFactor)
|
||||
|
||||
// Lerp toward target face rotation as we settle
|
||||
// Target rotation should show the correct face (from DICE_FACE_ROTATIONS)
|
||||
const lerpSpeed = 0.08 * settleFactor // Faster lerp as we get closer
|
||||
if (settleFactor > 0.01) {
|
||||
// Normalize rotations to find shortest path to target
|
||||
const targetX = targetFaceRotation.rotateX
|
||||
const targetY = targetFaceRotation.rotateY
|
||||
const targetZ = 0 // Final Z rotation should be 0 (flat)
|
||||
|
||||
// Normalize current rotation to -180 to 180 range for smooth interpolation
|
||||
const normalizeAngle = (angle: number) => {
|
||||
let normalized = angle % 360
|
||||
if (normalized > 180) normalized -= 360
|
||||
if (normalized < -180) normalized += 360
|
||||
return normalized
|
||||
}
|
||||
|
||||
const currentX = normalizeAngle(p.rotationX)
|
||||
const currentY = normalizeAngle(p.rotationY)
|
||||
const currentZ = normalizeAngle(p.rotationZ)
|
||||
|
||||
// Lerp each axis toward target
|
||||
p.rotationX = currentX + (targetX - currentX) * lerpSpeed
|
||||
p.rotationY = currentY + (targetY - currentY) * lerpSpeed
|
||||
p.rotationZ = currentZ + (targetZ - currentZ) * lerpSpeed
|
||||
}
|
||||
|
||||
// Update scale - grow when far/fast, shrink when close/slow
|
||||
const targetScale =
|
||||
dist > CLOSE_RANGE ? MAX_SCALE : 1 + ((MAX_SCALE - 1) * dist) / CLOSE_RANGE
|
||||
if (p.scale < targetScale) {
|
||||
p.scale = Math.min(p.scale + SCALE_GROW_SPEED, targetScale)
|
||||
} else if (p.scale > targetScale) {
|
||||
p.scale = Math.max(p.scale - SCALE_SHRINK_SPEED, targetScale)
|
||||
}
|
||||
|
||||
// Update DOM directly - no React re-renders
|
||||
// Scale from center, offset position to keep visual center stable
|
||||
const scaleOffset = ((p.scale - 1) * 22) / 2 // 22 is dice size
|
||||
el.style.transform = `translate(${p.x - scaleOffset}px, ${p.y - scaleOffset}px) scale(${p.scale})`
|
||||
|
||||
// Dynamic shadow based on scale (larger = higher = bigger shadow)
|
||||
const shadowSize = (p.scale - 1) * 10
|
||||
const shadowOpacity = Math.min((p.scale - 1) * 0.2, 0.4)
|
||||
el.style.filter =
|
||||
shadowSize > 0
|
||||
? `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
|
||||
: 'none'
|
||||
|
||||
// Update dice rotation
|
||||
const diceEl = el.querySelector('[data-dice-cube]') as HTMLElement | null
|
||||
if (diceEl) {
|
||||
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
|
||||
}
|
||||
|
||||
// Check if we should stop - snap to home when close, slow, small, AND rotation is settled
|
||||
const totalVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
|
||||
const rotationSettled =
|
||||
Math.abs(p.rotationX - targetFaceRotation.rotateX) < 5 &&
|
||||
Math.abs(p.rotationY - targetFaceRotation.rotateY) < 5 &&
|
||||
Math.abs(p.rotationZ) < 5
|
||||
|
||||
if (
|
||||
dist < STOP_THRESHOLD &&
|
||||
totalVelocity < VELOCITY_THRESHOLD &&
|
||||
p.scale < 1.1 &&
|
||||
rotationSettled
|
||||
) {
|
||||
// Dice has returned home - clear shadow
|
||||
el.style.filter = 'none'
|
||||
setIsFlying(false)
|
||||
dicePhysics.current = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: targetFaceRotation.rotateX,
|
||||
rotationY: targetFaceRotation.rotateY,
|
||||
rotationZ: 0,
|
||||
scale: 1,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [isFlying, diceOrigin.x, diceOrigin.y, targetFaceRotation.rotateX, targetFaceRotation.rotateY])
|
||||
|
||||
// Compute dice rotation for react-spring animation (used when not flying)
|
||||
const diceRotation = {
|
||||
rotateX: spinCount * 360 + targetFaceRotation.rotateX,
|
||||
rotateY: spinCount * 360 + targetFaceRotation.rotateY,
|
||||
rotateZ: spinCount * 180, // Z rotation for extra tumble effect
|
||||
}
|
||||
const isGenerating = status === 'generating'
|
||||
const [pageData, setPageData] = useState<{
|
||||
currentPage: number
|
||||
@@ -509,194 +61,7 @@ export function PreviewCenter({
|
||||
// Generate a new random seed (use modulo to keep it in 32-bit int range)
|
||||
const newSeed = Date.now() % 2147483647
|
||||
onChange({ seed: newSeed })
|
||||
|
||||
// Calculate target face from seed (2-6, excluding 1 so it's clearly a dice)
|
||||
// baseFace is deterministic based on seed
|
||||
const baseFace = (newSeed % 5) + 2
|
||||
|
||||
// Ensure it's different from the current face
|
||||
const targetFace = baseFace === currentFace ? (baseFace === 6 ? 2 : baseFace + 1) : baseFace
|
||||
|
||||
// Add 1-2 full spins for visual drama
|
||||
const extraSpins = Math.floor(Math.random() * 2) + 1
|
||||
|
||||
setSpinCount((prev) => prev + extraSpins)
|
||||
setCurrentFace(targetFace)
|
||||
}, [onChange, currentFace])
|
||||
|
||||
// Dice drag handlers for the easter egg - drag dice off and release to roll
|
||||
const handleDicePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (isGenerating) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Cancel any ongoing physics animation
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
setIsFlying(false)
|
||||
|
||||
// Capture the pointer
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
// Get the dice container's position for portal rendering
|
||||
if (diceContainerRef.current) {
|
||||
const rect = diceContainerRef.current.getBoundingClientRect()
|
||||
setDiceOrigin({ x: rect.left, y: rect.top })
|
||||
}
|
||||
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY }
|
||||
lastPointerPos.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
time: performance.now(),
|
||||
}
|
||||
velocitySamples.current = [] // Reset velocity tracking
|
||||
dicePhysics.current = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
scale: 1,
|
||||
}
|
||||
setIsDragging(true)
|
||||
},
|
||||
[isGenerating]
|
||||
)
|
||||
|
||||
const handleDicePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
const dx = e.clientX - dragStartPos.current.x
|
||||
const dy = e.clientY - dragStartPos.current.y
|
||||
|
||||
// Calculate drag velocity for live rotation
|
||||
const now = performance.now()
|
||||
const dt = Math.max(now - lastPointerPos.current.time, 8)
|
||||
const vx = (e.clientX - lastPointerPos.current.x) / dt
|
||||
const vy = (e.clientY - lastPointerPos.current.y) / dt
|
||||
|
||||
// Update rotation based on drag velocity (dice tumbles while being dragged)
|
||||
const p = dicePhysics.current
|
||||
p.rotationX += vy * 8
|
||||
p.rotationY -= vx * 8
|
||||
p.rotationZ += Math.sqrt(vx * vx + vy * vy) * 2
|
||||
|
||||
// Scale up slightly based on distance (feels like pulling it out)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
p.scale = 1 + Math.min(dist / 150, 0.5) // Max 1.5x during drag
|
||||
|
||||
// Update DOM directly to avoid React re-renders during drag
|
||||
if (portalDiceRef.current) {
|
||||
const scaleOffset = ((p.scale - 1) * 22) / 2
|
||||
portalDiceRef.current.style.transform = `translate(${dx - scaleOffset}px, ${dy - scaleOffset}px) scale(${p.scale})`
|
||||
// Add shadow that grows with distance
|
||||
const shadowSize = Math.min(dist / 10, 20)
|
||||
const shadowOpacity = Math.min(dist / 200, 0.4)
|
||||
portalDiceRef.current.style.filter = `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
|
||||
|
||||
// Update dice rotation
|
||||
const diceEl = portalDiceRef.current.querySelector('[data-dice-cube]') as HTMLElement | null
|
||||
if (diceEl) {
|
||||
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
|
||||
}
|
||||
}
|
||||
|
||||
// Store position in ref for use when releasing
|
||||
p.x = dx
|
||||
p.y = dy
|
||||
|
||||
// Track velocity samples for flick detection (keep last 5 samples, ~80ms window)
|
||||
velocitySamples.current.push({ vx, vy, time: now })
|
||||
if (velocitySamples.current.length > 5) {
|
||||
velocitySamples.current.shift()
|
||||
}
|
||||
|
||||
// Track velocity for throw calculation
|
||||
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: now }
|
||||
},
|
||||
[isDragging]
|
||||
)
|
||||
|
||||
const handleDicePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
// Release the pointer
|
||||
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
|
||||
// Calculate throw velocity from velocity samples (average of recent samples for smooth flick)
|
||||
const samples = velocitySamples.current
|
||||
let vx = 0
|
||||
let vy = 0
|
||||
|
||||
if (samples.length > 0) {
|
||||
// Weight recent samples more heavily
|
||||
let totalWeight = 0
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const weight = i + 1 // Later samples get higher weight
|
||||
vx += samples[i].vx * weight
|
||||
vy += samples[i].vy * weight
|
||||
totalWeight += weight
|
||||
}
|
||||
vx /= totalWeight
|
||||
vy /= totalWeight
|
||||
}
|
||||
|
||||
// Get position from physics ref (set during drag)
|
||||
const posX = dicePhysics.current.x
|
||||
const posY = dicePhysics.current.y
|
||||
|
||||
// Calculate distance dragged
|
||||
const distance = Math.sqrt(posX ** 2 + posY ** 2)
|
||||
|
||||
// Calculate flick speed
|
||||
const flickSpeed = Math.sqrt(vx * vx + vy * vy)
|
||||
|
||||
// If dragged more than 20px OR flicked fast enough, trigger throw physics
|
||||
if (distance > 20 || flickSpeed > 0.3) {
|
||||
// Amplify throw velocity significantly for satisfying flick
|
||||
const throwMultiplier = 25 // Much stronger throw!
|
||||
|
||||
// Initialize physics with current position and throw velocity
|
||||
dicePhysics.current = {
|
||||
x: posX,
|
||||
y: posY,
|
||||
vx: vx * throwMultiplier,
|
||||
vy: vy * throwMultiplier,
|
||||
rotationX: dicePhysics.current.rotationX, // Keep current rotation
|
||||
rotationY: dicePhysics.current.rotationY,
|
||||
rotationZ: dicePhysics.current.rotationZ,
|
||||
scale: dicePhysics.current.scale, // Keep current scale
|
||||
}
|
||||
setIsFlying(true)
|
||||
// Trigger shuffle when thrown
|
||||
handleShuffle()
|
||||
} else {
|
||||
// Not thrown far enough, snap back
|
||||
dicePhysics.current = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
scale: 1,
|
||||
}
|
||||
}
|
||||
|
||||
setIsDragging(false)
|
||||
},
|
||||
[isDragging, handleShuffle]
|
||||
)
|
||||
}, [onChange])
|
||||
|
||||
// Detect scrolling in the scroll container
|
||||
useEffect(() => {
|
||||
@@ -850,15 +215,8 @@ export function PreviewCenter({
|
||||
{/* Shuffle Button - only in edit mode (1/3 split secondary action) */}
|
||||
{/* Easter egg: drag the dice off and release to roll it back! */}
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
ref={diceButtonRef}
|
||||
type="button"
|
||||
data-action="shuffle-problems"
|
||||
onClick={handleShuffle}
|
||||
onPointerDown={handleDicePointerDown}
|
||||
onPointerMove={handleDicePointerMove}
|
||||
onPointerUp={handleDicePointerUp}
|
||||
onPointerCancel={handleDicePointerUp}
|
||||
<InteractiveDice
|
||||
onRoll={handleShuffle}
|
||||
disabled={isGenerating}
|
||||
title="Shuffle problems (drag or click to roll)"
|
||||
className={css({
|
||||
@@ -866,70 +224,18 @@ export function PreviewCenter({
|
||||
py: '2.5',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
cursor: isDragging ? 'grabbing' : isGenerating ? 'not-allowed' : 'grab',
|
||||
opacity: isGenerating ? '0.7' : '1',
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'brand.700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s',
|
||||
touchAction: 'none', // Prevent scroll on touch devices
|
||||
userSelect: 'none',
|
||||
_hover: isGenerating
|
||||
? {}
|
||||
: {
|
||||
bg: 'brand.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Dice container - hidden when dragging/flying since we show portal version */}
|
||||
<div
|
||||
ref={diceContainerRef}
|
||||
style={{
|
||||
opacity: isDragging || isFlying ? 0 : 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<DiceIcon
|
||||
rotateX={diceRotation.rotateX}
|
||||
rotateY={diceRotation.rotateY}
|
||||
rotateZ={diceRotation.rotateZ}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Portal-rendered dice when dragging/flying - renders outside button to avoid clipping */}
|
||||
{/* All transforms are applied directly via ref for performance - no React re-renders */}
|
||||
{(isDragging || isFlying) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={portalDiceRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: diceOrigin.x,
|
||||
top: diceOrigin.y,
|
||||
// Transform is updated directly via ref during drag/flying
|
||||
transform: 'translate(0px, 0px)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100000,
|
||||
cursor: isDragging ? 'grabbing' : 'default',
|
||||
willChange: 'transform', // Hint to browser for GPU acceleration
|
||||
}}
|
||||
>
|
||||
{/* During flying, rotation is updated directly on data-dice-cube element */}
|
||||
<DiceIcon
|
||||
rotateX={diceRotation.rotateX}
|
||||
rotateY={diceRotation.rotateY}
|
||||
rotateZ={diceRotation.rotateZ}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Dropdown Trigger */}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
|
||||
@@ -157,6 +157,7 @@ export default function FlowchartPage() {
|
||||
schema={state.flowchart.definition.problemInput}
|
||||
onSubmit={handleProblemSubmit}
|
||||
title="Enter your problem"
|
||||
flowchart={state.flowchart}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const messages = await getMessages(locale)
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body>
|
||||
<ClientProviders initialLocale={locale} initialMessages={messages}>
|
||||
{children}
|
||||
|
||||
@@ -51,9 +51,16 @@ export function FlowchartCheckpoint({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={vstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div
|
||||
data-testid="checkpoint-container"
|
||||
data-input-type={inputType}
|
||||
data-has-feedback={!!feedback}
|
||||
data-feedback-correct={feedback?.correct}
|
||||
className={vstack({ gap: '4', alignItems: 'center' })}
|
||||
>
|
||||
{/* Prompt */}
|
||||
<p
|
||||
data-testid="checkpoint-prompt"
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'medium',
|
||||
@@ -65,8 +72,9 @@ export function FlowchartCheckpoint({
|
||||
</p>
|
||||
|
||||
{/* Input and button */}
|
||||
<div className={hstack({ gap: '3' })}>
|
||||
<div data-testid="checkpoint-input-row" className={hstack({ gap: '3' })}>
|
||||
<input
|
||||
data-testid="checkpoint-input"
|
||||
type={inputType === 'number' ? 'number' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
@@ -104,6 +112,7 @@ export function FlowchartCheckpoint({
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
data-testid="checkpoint-check-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !value.trim()}
|
||||
className={css({
|
||||
@@ -131,6 +140,10 @@ export function FlowchartCheckpoint({
|
||||
{/* Feedback */}
|
||||
{feedback && (
|
||||
<div
|
||||
data-testid="checkpoint-feedback"
|
||||
data-feedback-correct={feedback.correct}
|
||||
data-expected={feedback.expected}
|
||||
data-user-answer={feedback.userAnswer}
|
||||
className={css({
|
||||
padding: '3 4',
|
||||
borderRadius: 'md',
|
||||
@@ -158,6 +171,7 @@ export function FlowchartCheckpoint({
|
||||
{/* Hint */}
|
||||
{hint && (
|
||||
<div
|
||||
data-testid="checkpoint-hint"
|
||||
className={css({
|
||||
padding: '3 4',
|
||||
borderRadius: 'md',
|
||||
|
||||
@@ -61,6 +61,10 @@ export function FlowchartDecision({
|
||||
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
data-testid="decision-radio-group"
|
||||
data-option-count={options.length}
|
||||
data-is-shaking={isShaking}
|
||||
data-showing-feedback={showFeedback}
|
||||
value={selectedValue}
|
||||
onValueChange={handleSelect}
|
||||
className={cx(
|
||||
@@ -68,13 +72,17 @@ export function FlowchartDecision({
|
||||
isShaking ? 'shake-animation' : ''
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
{options.map((option, idx) => {
|
||||
const isCorrect = showFeedback && correctAnswer === option.value
|
||||
const isWrong = showFeedback && wrongAnswer === option.value
|
||||
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
key={option.value}
|
||||
data-testid={`decision-option-${idx}`}
|
||||
data-option-value={option.value}
|
||||
data-is-correct={isCorrect}
|
||||
data-is-wrong={isWrong}
|
||||
value={option.value}
|
||||
disabled={showFeedback}
|
||||
className={css({
|
||||
@@ -267,6 +275,7 @@ interface WrongAnswerFeedbackProps {
|
||||
export function FlowchartWrongAnswerFeedback({ message }: WrongAnswerFeedbackProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="wrong-answer-feedback"
|
||||
className={css({
|
||||
padding: '3 4',
|
||||
backgroundColor: { base: 'amber.100', _dark: 'amber.900' },
|
||||
|
||||
@@ -16,9 +16,16 @@ interface FlowchartNodeContentProps {
|
||||
*/
|
||||
export function FlowchartNodeContent({ content, compact = false }: FlowchartNodeContentProps) {
|
||||
return (
|
||||
<div className={vstack({ gap: compact ? '2' : '3', alignItems: 'stretch' })}>
|
||||
<div
|
||||
data-testid="node-content"
|
||||
data-has-warning={!!content.warning}
|
||||
data-has-example={!!content.example}
|
||||
data-has-checklist={!!content.checklist?.length}
|
||||
className={vstack({ gap: compact ? '2' : '3', alignItems: 'stretch' })}
|
||||
>
|
||||
{/* Title */}
|
||||
<h3
|
||||
data-testid="node-content-title"
|
||||
className={css({
|
||||
fontSize: compact ? 'lg' : 'xl',
|
||||
fontWeight: 'bold',
|
||||
@@ -31,6 +38,8 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
|
||||
{/* Body */}
|
||||
{content.body.length > 0 && (
|
||||
<div
|
||||
data-testid="node-content-body"
|
||||
data-line-count={content.body.length}
|
||||
className={css({
|
||||
fontSize: compact ? 'sm' : 'md',
|
||||
color: { base: 'gray.700', _dark: 'gray.300' },
|
||||
@@ -48,6 +57,7 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
|
||||
{/* Warning */}
|
||||
{content.warning && (
|
||||
<div
|
||||
data-testid="node-content-warning"
|
||||
className={css({
|
||||
padding: '3',
|
||||
backgroundColor: { base: 'orange.100', _dark: 'orange.900' },
|
||||
@@ -65,6 +75,7 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
|
||||
{/* Example */}
|
||||
{content.example && (
|
||||
<div
|
||||
data-testid="node-content-example"
|
||||
className={css({
|
||||
padding: '3',
|
||||
backgroundColor: { base: 'blue.50', _dark: 'blue.900' },
|
||||
@@ -82,6 +93,8 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
|
||||
{/* Checklist */}
|
||||
{content.checklist && content.checklist.length > 0 && (
|
||||
<ul
|
||||
data-testid="node-content-checklist"
|
||||
data-item-count={content.checklist.length}
|
||||
className={css({
|
||||
listStyle: 'none',
|
||||
padding: '3',
|
||||
@@ -92,6 +105,7 @@ export function FlowchartNodeContent({ content, compact = false }: FlowchartNode
|
||||
{content.checklist.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
data-testid={`checklist-item-${i}`}
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: { base: 'green.800', _dark: 'green.200' },
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ExecutableFlowchart, FlowchartState } from '@/lib/flowcharts/schema'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import type { ExecutableFlowchart, FlowchartState, DecisionNode } from '@/lib/flowcharts/schema'
|
||||
import { css, cx } from '../../../styled-system/css'
|
||||
import { hstack, vstack } from '../../../styled-system/patterns'
|
||||
|
||||
interface FlowchartPhaseRailProps {
|
||||
flowchart: ExecutableFlowchart
|
||||
state: FlowchartState
|
||||
/** Callback when user selects a decision option */
|
||||
onDecisionSelect?: (value: string) => void
|
||||
/** Wrong answer for feedback animation */
|
||||
wrongDecision?: { value: string; correctValue: string; attempt: number } | null
|
||||
}
|
||||
|
||||
interface PathNode {
|
||||
@@ -20,10 +24,36 @@ interface PathNode {
|
||||
/**
|
||||
* Horizontal phase rail showing all phases with current phase expanded.
|
||||
* Shows path taken with ellipsis if more than 2 previous nodes.
|
||||
* When at a decision node, renders the options as clickable cards in the flowchart.
|
||||
*/
|
||||
export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps) {
|
||||
export function FlowchartPhaseRail({
|
||||
flowchart,
|
||||
state,
|
||||
onDecisionSelect,
|
||||
wrongDecision,
|
||||
}: FlowchartPhaseRailProps) {
|
||||
const phases = flowchart.mermaid.phases
|
||||
|
||||
// Shake animation state
|
||||
const [isShaking, setIsShaking] = useState(false)
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
|
||||
// Handle wrong answer feedback
|
||||
useEffect(() => {
|
||||
if (wrongDecision) {
|
||||
setIsShaking(true)
|
||||
setShowFeedback(true)
|
||||
|
||||
const shakeTimer = setTimeout(() => setIsShaking(false), 500)
|
||||
const feedbackTimer = setTimeout(() => setShowFeedback(false), 1500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(shakeTimer)
|
||||
clearTimeout(feedbackTimer)
|
||||
}
|
||||
}
|
||||
}, [wrongDecision?.attempt])
|
||||
|
||||
// Build the path taken through the flowchart
|
||||
const pathTaken = useMemo((): PathNode[] => {
|
||||
const path: PathNode[] = []
|
||||
@@ -64,6 +94,22 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
|
||||
return path
|
||||
}, [flowchart.nodes, state.history, state.currentNode])
|
||||
|
||||
// Get decision options for current node if it's a decision
|
||||
const decisionOptions = useMemo(() => {
|
||||
const currentNode = flowchart.nodes[state.currentNode]
|
||||
if (!currentNode || currentNode.definition.type !== 'decision') return null
|
||||
|
||||
const def = currentNode.definition as DecisionNode
|
||||
return def.options.map((opt) => {
|
||||
const nextNode = flowchart.nodes[opt.next]
|
||||
return {
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
leadsTo: nextNode?.content?.title || opt.next,
|
||||
}
|
||||
})
|
||||
}, [flowchart.nodes, state.currentNode])
|
||||
|
||||
// Find which phase the current node is in
|
||||
const currentPhaseIndex = useMemo(() => {
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
@@ -97,10 +143,18 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
|
||||
}
|
||||
}, [pathTaken])
|
||||
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (showFeedback) return
|
||||
onDecisionSelect?.(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={vstack({ gap: '3', alignItems: 'stretch' })}>
|
||||
<div data-testid="phase-rail" className={vstack({ gap: '3', alignItems: 'stretch' })}>
|
||||
{/* Horizontal phase rail */}
|
||||
<div
|
||||
data-testid="phase-rail-horizontal"
|
||||
data-current-phase={currentPhaseIndex}
|
||||
data-total-phases={phases.length}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '2',
|
||||
@@ -116,6 +170,9 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
|
||||
return (
|
||||
<div
|
||||
key={phase.id}
|
||||
data-testid={`phase-${idx}`}
|
||||
data-phase-id={phase.id}
|
||||
data-phase-status={status}
|
||||
className={css({
|
||||
flex: isCurrent ? '2 0 auto' : '1 0 auto',
|
||||
minWidth: isCurrent ? '160px' : '60px',
|
||||
@@ -173,9 +230,11 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded current phase - Path taken */}
|
||||
{/* Expanded current phase - Path taken + Decision */}
|
||||
{currentPhase && (
|
||||
<div
|
||||
data-testid="current-phase-expanded"
|
||||
data-phase-id={currentPhase.id}
|
||||
className={css({
|
||||
padding: '3',
|
||||
backgroundColor: { base: 'gray.50', _dark: 'gray.800' },
|
||||
@@ -184,26 +243,18 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
|
||||
borderColor: { base: 'gray.200', _dark: 'gray.700' },
|
||||
})}
|
||||
>
|
||||
<div className={vstack({ gap: '3', alignItems: 'stretch' })}>
|
||||
<div className={vstack({ gap: '4', alignItems: 'stretch' })}>
|
||||
{/* Path taken */}
|
||||
<div className={vstack({ gap: '1', alignItems: 'flex-start' })}>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '2xs',
|
||||
fontWeight: 'medium',
|
||||
color: { base: 'gray.500', _dark: 'gray.400' },
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'wide',
|
||||
})}
|
||||
>
|
||||
Path
|
||||
</span>
|
||||
<div data-testid="path-taken" className={vstack({ gap: '1', alignItems: 'center' })}>
|
||||
<div
|
||||
data-testid="path-nodes-container"
|
||||
data-path-length={pathTaken.length}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{displayPath.showEllipsis && (
|
||||
@@ -227,7 +278,13 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
|
||||
</>
|
||||
)}
|
||||
{displayPath.nodes.map((node, idx) => (
|
||||
<div key={node.nodeId} className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<div
|
||||
key={node.nodeId}
|
||||
data-testid={`path-node-${idx}`}
|
||||
data-node-id={node.nodeId}
|
||||
data-is-current={node.isCurrent}
|
||||
className={hstack({ gap: '2', alignItems: 'center' })}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
padding: '1 2',
|
||||
@@ -276,9 +333,215 @@ export function FlowchartPhaseRail({ flowchart, state }: FlowchartPhaseRailProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision branching - rendered as part of the flowchart */}
|
||||
{decisionOptions && onDecisionSelect && (
|
||||
<div
|
||||
data-testid="decision-branching"
|
||||
data-option-count={decisionOptions.length}
|
||||
className={vstack({ gap: '2', alignItems: 'center' })}
|
||||
>
|
||||
{/* Branch line */}
|
||||
<div
|
||||
className={css({
|
||||
width: '2px',
|
||||
height: '16px',
|
||||
backgroundColor: { base: 'gray.300', _dark: 'gray.600' },
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Branch split */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{/* Horizontal connector line */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'calc(100% - 80px)',
|
||||
height: '2px',
|
||||
backgroundColor: { base: 'gray.300', _dark: 'gray.600' },
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Option cards */}
|
||||
<div
|
||||
className={cx(
|
||||
hstack({ gap: '4', justifyContent: 'center', alignItems: 'stretch' }),
|
||||
isShaking ? 'shake-animation' : ''
|
||||
)}
|
||||
>
|
||||
{decisionOptions.map((opt, idx) => {
|
||||
const isCorrect = showFeedback && wrongDecision?.correctValue === opt.value
|
||||
const isWrong = showFeedback && wrongDecision?.value === opt.value
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
data-testid={`decision-option-${idx}`}
|
||||
data-option-value={opt.value}
|
||||
data-is-correct={isCorrect}
|
||||
data-is-wrong={isWrong}
|
||||
className={vstack({ gap: '0', alignItems: 'center' })}
|
||||
>
|
||||
{/* Vertical connector */}
|
||||
<div
|
||||
className={css({
|
||||
width: '2px',
|
||||
height: '12px',
|
||||
backgroundColor: { base: 'gray.300', _dark: 'gray.600' },
|
||||
})}
|
||||
/>
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className={css({
|
||||
width: '0',
|
||||
height: '0',
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '8px solid',
|
||||
borderTopColor: { base: 'gray.300', _dark: 'gray.600' },
|
||||
marginBottom: '2',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Clickable option card */}
|
||||
<button
|
||||
data-testid={`decision-option-button-${idx}`}
|
||||
onClick={() => handleOptionClick(opt.value)}
|
||||
disabled={showFeedback}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '0',
|
||||
borderRadius: 'lg',
|
||||
border: '3px solid',
|
||||
cursor: showFeedback ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
minWidth: '120px',
|
||||
maxWidth: '160px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: { base: 'white', _dark: 'gray.700' },
|
||||
|
||||
borderColor: isCorrect
|
||||
? { base: 'green.500', _dark: 'green.400' }
|
||||
: isWrong
|
||||
? { base: 'red.500', _dark: 'red.400' }
|
||||
: { base: 'blue.300', _dark: 'blue.600' },
|
||||
|
||||
_hover: showFeedback
|
||||
? {}
|
||||
: {
|
||||
borderColor: { base: 'blue.500', _dark: 'blue.400' },
|
||||
transform: 'scale(1.03)',
|
||||
boxShadow: 'lg',
|
||||
},
|
||||
|
||||
_active: showFeedback
|
||||
? {}
|
||||
: {
|
||||
transform: 'scale(0.97)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Choice header */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '2 3',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
backgroundColor: isCorrect
|
||||
? { base: 'green.100', _dark: 'green.800' }
|
||||
: isWrong
|
||||
? { base: 'red.100', _dark: 'red.800' }
|
||||
: { base: 'blue.100', _dark: 'blue.800' },
|
||||
color: isCorrect
|
||||
? { base: 'green.800', _dark: 'green.200' }
|
||||
: isWrong
|
||||
? { base: 'red.800', _dark: 'red.200' }
|
||||
: { base: 'blue.800', _dark: 'blue.200' },
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isCorrect
|
||||
? { base: 'green.200', _dark: 'green.600' }
|
||||
: isWrong
|
||||
? { base: 'red.200', _dark: 'red.600' }
|
||||
: { base: 'blue.200', _dark: 'blue.600' },
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{isCorrect && (
|
||||
<span
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
fontSize: 'xs',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{isWrong && (
|
||||
<span
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
fontSize: 'xs',
|
||||
})}
|
||||
>
|
||||
✗
|
||||
</span>
|
||||
)}
|
||||
{opt.label}
|
||||
</div>
|
||||
|
||||
{/* Where it leads */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '2',
|
||||
fontSize: '2xs',
|
||||
color: { base: 'gray.600', _dark: 'gray.400' },
|
||||
textAlign: 'center',
|
||||
lineHeight: 'tight',
|
||||
})}
|
||||
>
|
||||
{opt.leadsTo.length > 25 ? opt.leadsTo.slice(0, 23) + '…' : opt.leadsTo}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shake animation styles */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-8px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(8px); }
|
||||
}
|
||||
.shake-animation {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useMemo, useRef } from 'react'
|
||||
import type {
|
||||
ProblemInputSchema,
|
||||
Field,
|
||||
ProblemValue,
|
||||
MixedNumberValue,
|
||||
ExecutableFlowchart,
|
||||
} from '@/lib/flowcharts/schema'
|
||||
import { evaluate, createEmptyContext } from '@/lib/flowcharts/evaluator'
|
||||
import { evaluate } from '@/lib/flowcharts/evaluator'
|
||||
import {
|
||||
generateDiverseExamples,
|
||||
analyzeFlowchart,
|
||||
type GeneratedExample,
|
||||
type FlowchartAnalysis,
|
||||
type GenerationConstraints,
|
||||
DEFAULT_CONSTRAINTS,
|
||||
} from '@/lib/flowcharts/loader'
|
||||
import { InteractiveDice } from '@/components/ui/InteractiveDice'
|
||||
import { TeacherConfigPanel } from './TeacherConfigPanel'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { vstack, hstack } from '../../../styled-system/patterns'
|
||||
import { MathDisplay } from './MathDisplay'
|
||||
|
||||
interface FlowchartProblemInputProps {
|
||||
schema: ProblemInputSchema
|
||||
onSubmit: (values: Record<string, ProblemValue>) => void
|
||||
title?: string
|
||||
/** The loaded flowchart (used to calculate path complexity for examples) */
|
||||
flowchart?: ExecutableFlowchart
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic problem input form based on schema definition.
|
||||
* Renders appropriate input fields based on field types.
|
||||
*/
|
||||
export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProblemInputProps) {
|
||||
export function FlowchartProblemInput({ schema, onSubmit, title, flowchart }: FlowchartProblemInputProps) {
|
||||
const [values, setValues] = useState<Record<string, ProblemValue>>(() =>
|
||||
initializeValues(schema.fields)
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedExampleIdx, setSelectedExampleIdx] = useState<number | null>(null)
|
||||
// Teacher-configurable constraints for problem generation
|
||||
const [constraints, setConstraints] = useState<GenerationConstraints>(DEFAULT_CONSTRAINTS)
|
||||
// Displayed examples - updated when dice roll completes
|
||||
const [displayedExamples, setDisplayedExamples] = useState<GeneratedExample[]>([])
|
||||
// Pending examples - pre-computed during drag, shown on roll complete
|
||||
const pendingExamplesRef = useRef<GeneratedExample[] | null>(null)
|
||||
|
||||
// Analyze flowchart structure (paths, complexity, etc.)
|
||||
const analysis = useMemo(() => {
|
||||
if (!flowchart) return null
|
||||
try {
|
||||
return analyzeFlowchart(flowchart)
|
||||
} catch (e) {
|
||||
console.error('Error analyzing flowchart:', e)
|
||||
return null
|
||||
}
|
||||
}, [flowchart])
|
||||
|
||||
// Generate initial examples on first render (re-generates when constraints change)
|
||||
const initialExamples = useMemo(() => {
|
||||
if (!flowchart) return []
|
||||
try {
|
||||
return generateDiverseExamples(flowchart, 6, constraints)
|
||||
} catch (e) {
|
||||
console.error('Error generating examples:', e)
|
||||
return []
|
||||
}
|
||||
}, [flowchart, constraints])
|
||||
|
||||
// Set initial examples on first render
|
||||
const generatedExamples = displayedExamples.length > 0 ? displayedExamples : initialExamples
|
||||
|
||||
// Calculate coverage: how many unique paths are represented in current examples
|
||||
const pathsCovered = useMemo(() => {
|
||||
const uniquePaths = new Set(generatedExamples.map(ex => ex.pathSignature))
|
||||
return uniquePaths.size
|
||||
}, [generatedExamples])
|
||||
|
||||
// Precompute new examples when user starts dragging the dice
|
||||
const handleDragStart = useCallback(() => {
|
||||
if (!flowchart) return
|
||||
try {
|
||||
// Compute new examples during drag - user won't notice the computation
|
||||
pendingExamplesRef.current = generateDiverseExamples(flowchart, 6, constraints)
|
||||
} catch (e) {
|
||||
console.error('Error pre-generating examples:', e)
|
||||
}
|
||||
}, [flowchart, constraints])
|
||||
|
||||
// Show new examples after the dice roll animation completes
|
||||
const handleRollComplete = useCallback(() => {
|
||||
// If we have pre-computed examples from drag, use them
|
||||
if (pendingExamplesRef.current) {
|
||||
setDisplayedExamples(pendingExamplesRef.current)
|
||||
pendingExamplesRef.current = null
|
||||
} else if (flowchart) {
|
||||
// Click-only roll - compute now (animation masked the compute time)
|
||||
try {
|
||||
setDisplayedExamples(generateDiverseExamples(flowchart, 6, constraints))
|
||||
} catch (e) {
|
||||
console.error('Error generating examples:', e)
|
||||
}
|
||||
}
|
||||
setSelectedExampleIdx(null)
|
||||
}, [flowchart, constraints])
|
||||
|
||||
// Handler for dice roll - we don't update examples here anymore
|
||||
// (examples update on completion via handleRollComplete)
|
||||
const handleRoll = useCallback(() => {
|
||||
// The actual example update happens in handleRollComplete
|
||||
// This is called immediately when rolled - could add a spinning indicator here
|
||||
}, [])
|
||||
|
||||
const handleChange = useCallback((name: string, value: ProblemValue) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }))
|
||||
setError(null)
|
||||
setSelectedExampleIdx(null) // Clear example selection when user manually changes
|
||||
}, [])
|
||||
|
||||
// Handle constraint changes - regenerate examples with new constraints
|
||||
const handleConstraintsChange = useCallback((newConstraints: GenerationConstraints) => {
|
||||
setConstraints(newConstraints)
|
||||
setDisplayedExamples([]) // Clear displayed examples so they regenerate with new constraints
|
||||
setSelectedExampleIdx(null)
|
||||
}, [])
|
||||
|
||||
const handleExampleSelect = useCallback((example: GeneratedExample, idx: number) => {
|
||||
setValues(example.values)
|
||||
setSelectedExampleIdx(idx)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -67,6 +168,7 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="flowchart-problem-input"
|
||||
className={vstack({
|
||||
gap: '6',
|
||||
padding: '6',
|
||||
@@ -79,6 +181,13 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Teacher configuration panel */}
|
||||
<TeacherConfigPanel
|
||||
constraints={constraints}
|
||||
onConstraintsChange={handleConstraintsChange}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{title && (
|
||||
<h2
|
||||
className={css({
|
||||
@@ -99,9 +208,127 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Enter your problem to get started
|
||||
{generatedExamples.length > 0 ? 'Choose an example or enter your own' : 'Enter your problem to get started'}
|
||||
</p>
|
||||
|
||||
{/* Monte Carlo generated example problem buttons */}
|
||||
{generatedExamples.length > 0 && (
|
||||
<div data-testid="examples-section" className={vstack({ gap: '2', alignItems: 'stretch' })}>
|
||||
<div className={vstack({ gap: '1', alignItems: 'center' })}>
|
||||
<div className={hstack({ gap: '2', justifyContent: 'center', alignItems: 'center' })}>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: { base: 'gray.500', _dark: 'gray.400' },
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'wide',
|
||||
})}
|
||||
>
|
||||
Try These
|
||||
</span>
|
||||
<InteractiveDice
|
||||
onRoll={handleRoll}
|
||||
onDragStart={handleDragStart}
|
||||
onRollComplete={handleRollComplete}
|
||||
size={18}
|
||||
title="Roll for new examples"
|
||||
className={css({
|
||||
padding: '1',
|
||||
borderRadius: 'md',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
transition: 'background 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: { base: 'gray.100', _dark: 'gray.700' },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{/* Coverage stats */}
|
||||
{analysis && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '2xs',
|
||||
color: { base: 'gray.400', _dark: 'gray.500' },
|
||||
})}
|
||||
title={`${analysis.stats.totalPaths} unique paths, ${analysis.stats.minDecisions}-${analysis.stats.maxDecisions} decisions, ${analysis.stats.minCheckpoints}-${analysis.stats.maxCheckpoints} checkpoints`}
|
||||
>
|
||||
{pathsCovered}/{analysis.stats.totalPaths} paths • {analysis.stats.minPathLength}-{analysis.stats.maxPathLength} steps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '2',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{generatedExamples.map((example, idx) => {
|
||||
const { complexity } = example
|
||||
return (
|
||||
<button
|
||||
key={`${example.pathSignature}-${idx}`}
|
||||
data-testid={`example-button-${idx}`}
|
||||
data-path-signature={example.pathSignature}
|
||||
data-complexity-path={complexity.pathLength}
|
||||
data-complexity-decisions={complexity.decisions}
|
||||
data-complexity-checkpoints={complexity.checkpoints}
|
||||
onClick={() => handleExampleSelect(example, idx)}
|
||||
title={`Path: ${complexity.pathLength} steps, ${complexity.decisions} decisions, ${complexity.checkpoints} checkpoints`}
|
||||
className={css({
|
||||
padding: '2 3',
|
||||
borderRadius: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: selectedExampleIdx === idx
|
||||
? { base: 'blue.500', _dark: 'blue.400' }
|
||||
: { base: 'gray.200', _dark: 'gray.600' },
|
||||
backgroundColor: selectedExampleIdx === idx
|
||||
? { base: 'blue.50', _dark: 'blue.900' }
|
||||
: { base: 'white', _dark: 'gray.800' },
|
||||
color: selectedExampleIdx === idx
|
||||
? { base: 'blue.700', _dark: 'blue.200' }
|
||||
: { base: 'gray.700', _dark: 'gray.300' },
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: { base: 'blue.400', _dark: 'blue.500' },
|
||||
backgroundColor: { base: 'blue.50', _dark: 'blue.900/50' },
|
||||
},
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1',
|
||||
})}
|
||||
>
|
||||
<MathDisplay
|
||||
expression={formatExampleDisplay(schema.schema, example.values)}
|
||||
size="sm"
|
||||
/>
|
||||
<div
|
||||
data-testid="path-descriptor"
|
||||
data-path-descriptor={example.pathDescriptor}
|
||||
data-path-length={complexity.pathLength}
|
||||
data-decisions={complexity.decisions}
|
||||
data-checkpoints={complexity.checkpoints}
|
||||
className={css({
|
||||
fontSize: '2xs',
|
||||
color: { base: 'gray.500', _dark: 'gray.400' },
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
title={`${complexity.pathLength} steps, ${complexity.decisions} decisions, ${complexity.checkpoints} checkpoints`}
|
||||
>
|
||||
{example.pathDescriptor}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render fields based on schema */}
|
||||
{schema.schema === 'two-digit-subtraction' ? (
|
||||
<TwoDigitSubtractionInput values={values} onChange={handleChange} />
|
||||
@@ -128,6 +355,7 @@ export function FlowchartProblemInput({ schema, onSubmit, title }: FlowchartProb
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
data-testid="start-button"
|
||||
onClick={handleSubmit}
|
||||
className={css({
|
||||
width: '100%',
|
||||
@@ -576,6 +804,44 @@ function renderFieldInput(
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format example values into a readable problem expression based on schema type
|
||||
*/
|
||||
function formatExampleDisplay(schema: string, values: Record<string, ProblemValue>): string {
|
||||
switch (schema) {
|
||||
case 'two-digit-subtraction':
|
||||
return `${values.minuend} − ${values.subtrahend}`
|
||||
|
||||
case 'linear-equation': {
|
||||
const coef = values.coefficient as number
|
||||
const op = values.operation as string
|
||||
const constant = values.constant as number
|
||||
const equals = values.equals as number
|
||||
if (constant === 0) {
|
||||
return `${coef}x = ${equals}`
|
||||
}
|
||||
return `${coef}x ${op} ${constant} = ${equals}`
|
||||
}
|
||||
|
||||
case 'two-fractions-with-op': {
|
||||
const lw = values.leftWhole as number
|
||||
const ln = values.leftNum as number
|
||||
const ld = values.leftDenom as number
|
||||
const op = values.op as string
|
||||
const rw = values.rightWhole as number
|
||||
const rn = values.rightNum as number
|
||||
const rd = values.rightDenom as number
|
||||
|
||||
const left = lw > 0 ? `${lw} ${ln}/${ld}` : `${ln}/${ld}`
|
||||
const right = rw > 0 ? `${rw} ${rn}/${rd}` : `${rn}/${rd}`
|
||||
return `${left} ${op} ${right}`
|
||||
}
|
||||
|
||||
default:
|
||||
return JSON.stringify(values)
|
||||
}
|
||||
}
|
||||
|
||||
function initializeValues(fields: Field[]): Record<string, ProblemValue> {
|
||||
const values: Record<string, ProblemValue> = {}
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ export function FlowchartWalker({
|
||||
case 'instruction':
|
||||
return (
|
||||
<button
|
||||
data-testid="instruction-advance-button"
|
||||
onClick={handleInstructionAdvance}
|
||||
className={css({
|
||||
padding: '4 8',
|
||||
@@ -280,6 +281,7 @@ export function FlowchartWalker({
|
||||
if (phase.correct) {
|
||||
return (
|
||||
<div
|
||||
data-testid="checkpoint-correct-feedback"
|
||||
className={css({
|
||||
padding: '4',
|
||||
backgroundColor: { base: 'green.100', _dark: 'green.800' },
|
||||
@@ -296,7 +298,7 @@ export function FlowchartWalker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={vstack({ gap: '4' })}>
|
||||
<div data-testid="checkpoint-wrong-feedback" className={vstack({ gap: '4' })}>
|
||||
<FlowchartCheckpoint
|
||||
prompt={checkpointDef.prompt}
|
||||
inputType={checkpointDef.inputType}
|
||||
@@ -309,6 +311,7 @@ export function FlowchartWalker({
|
||||
hint={showHint ? `Hint: The answer is ${phase.expected}` : undefined}
|
||||
/>
|
||||
<button
|
||||
data-testid="checkpoint-retry-button"
|
||||
onClick={handleCheckpointRetry}
|
||||
className={css({
|
||||
padding: '2 4',
|
||||
@@ -339,6 +342,7 @@ export function FlowchartWalker({
|
||||
setTimeout(() => advanceToNext(), 500)
|
||||
return (
|
||||
<div
|
||||
data-testid="milestone-display"
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
textAlign: 'center',
|
||||
@@ -363,6 +367,8 @@ export function FlowchartWalker({
|
||||
if (phase.type === 'complete') {
|
||||
return (
|
||||
<div
|
||||
data-testid="completion-screen"
|
||||
data-mistakes={state.mistakes}
|
||||
className={vstack({
|
||||
gap: '6',
|
||||
padding: '8',
|
||||
@@ -371,7 +377,7 @@ export function FlowchartWalker({
|
||||
minHeight: '400px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '6xl' })}>🎉</div>
|
||||
<div data-testid="celebration-emoji" className={css({ fontSize: '6xl' })}>🎉</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
@@ -391,6 +397,7 @@ export function FlowchartWalker({
|
||||
</p>
|
||||
{onRestart && (
|
||||
<button
|
||||
data-testid="restart-button"
|
||||
onClick={onRestart}
|
||||
className={css({
|
||||
padding: '3 6',
|
||||
@@ -411,9 +418,14 @@ export function FlowchartWalker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={vstack({ gap: '6', padding: '4', alignItems: 'stretch' })}>
|
||||
<div
|
||||
data-testid="flowchart-walker"
|
||||
data-current-node={state.currentNode}
|
||||
data-phase={phase.type}
|
||||
className={vstack({ gap: '6', padding: '4', alignItems: 'stretch' })}
|
||||
>
|
||||
{/* Problem display header */}
|
||||
<div className={hstack({ justifyContent: 'center', fontSize: 'sm' })}>
|
||||
<div data-testid="problem-header" className={hstack({ justifyContent: 'center', fontSize: 'sm' })}>
|
||||
<span className={css({ color: { base: 'gray.500', _dark: 'gray.500' } })}>
|
||||
{problemDisplay}
|
||||
</span>
|
||||
@@ -425,6 +437,8 @@ export function FlowchartWalker({
|
||||
{/* Working problem ledger */}
|
||||
{state.workingProblemHistory.length > 0 && (
|
||||
<div
|
||||
data-testid="working-problem-ledger"
|
||||
data-step-count={state.workingProblemHistory.length}
|
||||
className={css({
|
||||
padding: '4',
|
||||
backgroundColor: { base: 'blue.50', _dark: 'blue.900' },
|
||||
@@ -456,6 +470,10 @@ export function FlowchartWalker({
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
data-testid={`ledger-step-${idx}`}
|
||||
data-step-index={idx}
|
||||
data-is-latest={isLatest}
|
||||
data-node-id={step.nodeId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -517,6 +535,8 @@ export function FlowchartWalker({
|
||||
|
||||
{/* Node content */}
|
||||
<div
|
||||
data-testid="node-content-container"
|
||||
data-node-type={currentNode?.definition.type}
|
||||
className={css({
|
||||
padding: '6',
|
||||
backgroundColor: { base: 'white', _dark: 'gray.800' },
|
||||
@@ -531,6 +551,7 @@ export function FlowchartWalker({
|
||||
|
||||
{/* Interaction area */}
|
||||
<div
|
||||
data-testid="interaction-area"
|
||||
className={css({
|
||||
padding: '4',
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { hstack } from '../../../styled-system/patterns'
|
||||
|
||||
interface MathDisplayProps {
|
||||
/** Math expression string to render (e.g., "3 2/9 − 1 1/2" or "2x = 12") */
|
||||
@@ -11,8 +10,10 @@ interface MathDisplayProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders math expressions with proper fraction formatting.
|
||||
* Parses strings like "3 2/9 − 1 1/2" and renders fractions stacked.
|
||||
* Renders math expressions using native MathML.
|
||||
* Parses strings like "3 2/9 − 1 1/2" and renders proper semantic math.
|
||||
*
|
||||
* Browser support: 94%+ (Chrome 109+, Firefox, Safari 10+, Edge 109+)
|
||||
*/
|
||||
export function MathDisplay({ expression, size = 'lg' }: MathDisplayProps) {
|
||||
const fontSize = {
|
||||
@@ -26,19 +27,22 @@ export function MathDisplay({ expression, size = 'lg' }: MathDisplayProps) {
|
||||
const tokens = parseExpression(expression)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={hstack({
|
||||
gap: '2',
|
||||
<math
|
||||
data-testid="math-display"
|
||||
data-expression={expression}
|
||||
data-size={size}
|
||||
data-token-count={tokens.length}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
verticalAlign: 'middle',
|
||||
})}
|
||||
style={{ fontSize }}
|
||||
>
|
||||
{tokens.map((token, idx) => (
|
||||
<span key={idx}>{renderToken(token)}</span>
|
||||
))}
|
||||
</span>
|
||||
<mrow>
|
||||
{tokens.map((token, idx) => renderToken(token, idx))}
|
||||
</mrow>
|
||||
</math>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,110 +121,45 @@ function parseExpression(expr: string): Token[] {
|
||||
return tokens
|
||||
}
|
||||
|
||||
function renderToken(token: Token): React.ReactNode {
|
||||
function renderToken(token: Token, key: number): React.ReactNode {
|
||||
switch (token.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.value}</span>
|
||||
)
|
||||
return <mn key={key}>{token.value}</mn>
|
||||
|
||||
case 'fraction':
|
||||
return (
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: 1.1,
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.numerator}</span>
|
||||
<span
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '2px',
|
||||
backgroundColor: 'currentColor',
|
||||
margin: '1px 0',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.denominator}</span>
|
||||
</span>
|
||||
<mfrac key={key}>
|
||||
<mn>{token.numerator}</mn>
|
||||
<mn>{token.denominator}</mn>
|
||||
</mfrac>
|
||||
)
|
||||
|
||||
case 'mixed':
|
||||
return (
|
||||
<span className={hstack({ gap: '1', alignItems: 'center' })}>
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.whole}</span>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: 1.1,
|
||||
fontSize: '0.85em',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.numerator}</span>
|
||||
<span
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '2px',
|
||||
backgroundColor: 'currentColor',
|
||||
margin: '1px 0',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.denominator}</span>
|
||||
</span>
|
||||
</span>
|
||||
<mrow key={key}>
|
||||
<mn>{token.whole}</mn>
|
||||
<mfrac>
|
||||
<mn>{token.numerator}</mn>
|
||||
<mn>{token.denominator}</mn>
|
||||
</mfrac>
|
||||
</mrow>
|
||||
)
|
||||
|
||||
case 'operator':
|
||||
return (
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'normal',
|
||||
padding: '0 0.25em',
|
||||
opacity: 0.8,
|
||||
})}
|
||||
>
|
||||
{token.value}
|
||||
</span>
|
||||
)
|
||||
return <mo key={key}>{token.value}</mo>
|
||||
|
||||
case 'variable':
|
||||
return (
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontStyle: 'italic',
|
||||
color: 'purple.600',
|
||||
})}
|
||||
>
|
||||
{token.name}
|
||||
</span>
|
||||
)
|
||||
return <mi key={key}>{token.name}</mi>
|
||||
|
||||
case 'term':
|
||||
return (
|
||||
<span className={css({ display: 'inline-flex', alignItems: 'baseline' })}>
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.coefficient}</span>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontStyle: 'italic',
|
||||
color: 'purple.600',
|
||||
})}
|
||||
>
|
||||
{token.variable}
|
||||
</span>
|
||||
</span>
|
||||
<mrow key={key}>
|
||||
<mn>{token.coefficient}</mn>
|
||||
<mi>{token.variable}</mi>
|
||||
</mrow>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<span className={css({ fontWeight: 'bold' })}>{token.value}</span>
|
||||
)
|
||||
return <mtext key={key}>{token.value}</mtext>
|
||||
}
|
||||
}
|
||||
|
||||
196
apps/web/src/components/flowchart/TeacherConfigPanel.tsx
Normal file
196
apps/web/src/components/flowchart/TeacherConfigPanel.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import * as Switch from '@radix-ui/react-switch'
|
||||
import type { GenerationConstraints } from '@/lib/flowcharts/loader'
|
||||
import { DEFAULT_CONSTRAINTS } from '@/lib/flowcharts/loader'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { vstack, hstack } from '../../../styled-system/patterns'
|
||||
|
||||
interface TeacherConfigPanelProps {
|
||||
/** Current constraints */
|
||||
constraints: GenerationConstraints
|
||||
/** Called when constraints change */
|
||||
onConstraintsChange: (constraints: GenerationConstraints) => void
|
||||
/** Whether the panel is collapsed by default */
|
||||
defaultCollapsed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Teacher-facing configuration panel for controlling problem generation constraints.
|
||||
* Collapsible panel with settings like "positive answers only".
|
||||
*/
|
||||
export function TeacherConfigPanel({
|
||||
constraints,
|
||||
onConstraintsChange,
|
||||
defaultCollapsed = true,
|
||||
}: TeacherConfigPanelProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(key: keyof GenerationConstraints) => {
|
||||
onConstraintsChange({
|
||||
...constraints,
|
||||
[key]: !constraints[key],
|
||||
})
|
||||
},
|
||||
[constraints, onConstraintsChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="teacher-config-panel"
|
||||
data-collapsed={isCollapsed}
|
||||
className={css({
|
||||
backgroundColor: { base: 'gray.50', _dark: 'gray.800' },
|
||||
borderRadius: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: { base: 'gray.200', _dark: 'gray.700' },
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
{/* Header - always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '2 3',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: { base: 'gray.600', _dark: 'gray.400' },
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: { base: 'gray.100', _dark: 'gray.700' },
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<span>⚙️</span>
|
||||
<span>Teacher Settings</span>
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
transition: 'transform 0.2s',
|
||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(180deg)',
|
||||
})}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Collapsible content */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className={vstack({
|
||||
gap: '3',
|
||||
padding: '3',
|
||||
paddingTop: '0',
|
||||
alignItems: 'stretch',
|
||||
})}
|
||||
>
|
||||
{/* Positive Answers Only */}
|
||||
<ConfigSwitch
|
||||
id="positive-answers"
|
||||
label="Positive answers only"
|
||||
description="Generated problems will always have non-negative results"
|
||||
checked={constraints.positiveAnswersOnly ?? DEFAULT_CONSTRAINTS.positiveAnswersOnly ?? true}
|
||||
onCheckedChange={() => handleToggle('positiveAnswersOnly')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfigSwitchProps {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
function ConfigSwitch({ id, label, description, checked, onCheckedChange }: ConfigSwitchProps) {
|
||||
return (
|
||||
<div
|
||||
className={hstack({
|
||||
justifyContent: 'space-between',
|
||||
gap: '3',
|
||||
padding: '2',
|
||||
borderRadius: 'md',
|
||||
backgroundColor: { base: 'white', _dark: 'gray.900' },
|
||||
border: '1px solid',
|
||||
borderColor: { base: 'gray.200', _dark: 'gray.700' },
|
||||
})}
|
||||
>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={vstack({
|
||||
gap: '0.5',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: { base: 'gray.800', _dark: 'gray.200' },
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: { base: 'gray.500', _dark: 'gray.500' },
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</label>
|
||||
<Switch.Root
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className={css({
|
||||
width: '42px',
|
||||
height: '24px',
|
||||
backgroundColor: checked
|
||||
? { base: 'green.500', _dark: 'green.600' }
|
||||
: { base: 'gray.300', _dark: 'gray.600' },
|
||||
borderRadius: 'full',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
flexShrink: 0,
|
||||
_focusVisible: {
|
||||
outline: '2px solid',
|
||||
outlineColor: { base: 'blue.500', _dark: 'blue.400' },
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Switch.Thumb
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 'full',
|
||||
boxShadow: 'sm',
|
||||
transition: 'transform 0.2s',
|
||||
transform: checked ? 'translateX(20px)' : 'translateX(2px)',
|
||||
})}
|
||||
/>
|
||||
</Switch.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
809
apps/web/src/components/ui/InteractiveDice.tsx
Normal file
809
apps/web/src/components/ui/InteractiveDice.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
||||
// Dot patterns for each dice face (positions as fractions of face size)
|
||||
const DICE_DOT_PATTERNS: Record<number, Array<[number, number]>> = {
|
||||
1: [[0.5, 0.5]],
|
||||
2: [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
3: [
|
||||
[0.25, 0.25],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
4: [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.25],
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
5: [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.25],
|
||||
[0.5, 0.5],
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75],
|
||||
],
|
||||
6: [
|
||||
[0.25, 0.2],
|
||||
[0.25, 0.5],
|
||||
[0.25, 0.8],
|
||||
[0.75, 0.2],
|
||||
[0.75, 0.5],
|
||||
[0.75, 0.8],
|
||||
],
|
||||
}
|
||||
|
||||
// Rotation needed to show each face
|
||||
// Standard dice: 1 opposite 6, 2 opposite 5, 3 opposite 4
|
||||
const DICE_FACE_ROTATIONS: Record<number, { rotateX: number; rotateY: number }> = {
|
||||
1: { rotateX: 0, rotateY: 0 }, // front
|
||||
2: { rotateX: 0, rotateY: -90 }, // right
|
||||
3: { rotateX: -90, rotateY: 0 }, // top
|
||||
4: { rotateX: 90, rotateY: 0 }, // bottom
|
||||
5: { rotateX: 0, rotateY: 90 }, // left
|
||||
6: { rotateX: 0, rotateY: 180 }, // back
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D Dice Icon using react-spring for smooth animations
|
||||
*
|
||||
* Creates a cube with 6 faces, each showing the appropriate dot pattern.
|
||||
* The cube rotates on all 3 axes when rolling, with physics-based easing.
|
||||
*/
|
||||
function DiceIcon({
|
||||
className,
|
||||
rotateX,
|
||||
rotateY,
|
||||
rotateZ,
|
||||
isDark,
|
||||
size = 22,
|
||||
}: {
|
||||
className?: string
|
||||
rotateX: number
|
||||
rotateY: number
|
||||
rotateZ: number
|
||||
isDark: boolean
|
||||
size?: number
|
||||
}) {
|
||||
const halfSize = size / 2
|
||||
|
||||
// Animate rotation with react-spring
|
||||
const springProps = useSpring({
|
||||
rotateX,
|
||||
rotateY,
|
||||
rotateZ,
|
||||
config: {
|
||||
tension: 120,
|
||||
friction: 14,
|
||||
},
|
||||
})
|
||||
|
||||
// Theme-aware colors
|
||||
// Dark mode: lighter indigo with more contrast against dark backgrounds
|
||||
// Light mode: deeper indigo that stands out against light backgrounds
|
||||
const faceBackground = isDark ? '#818cf8' : '#4f46e5' // indigo-400 dark, indigo-600 light
|
||||
const faceBorder = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(255, 255, 255, 0.5)'
|
||||
const dotColor = isDark ? '#1e1b4b' : 'white' // indigo-950 dots on light bg in dark mode
|
||||
|
||||
// Render dots for a face
|
||||
const renderDots = (face: number) => {
|
||||
const dots = DICE_DOT_PATTERNS[face] || []
|
||||
return dots.map(([x, y], i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
width: '18%',
|
||||
height: '18%',
|
||||
backgroundColor: dotColor,
|
||||
borderRadius: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
// Common face styles - opaque background to prevent artifacts during rotation
|
||||
const faceStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: faceBackground,
|
||||
border: `1.5px solid ${faceBorder}`,
|
||||
borderRadius: 2,
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
perspective: 100,
|
||||
perspectiveOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<animated.div
|
||||
data-dice-cube
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
position: 'relative',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: springProps.rotateX.to(
|
||||
(rx) =>
|
||||
`rotateX(${rx}deg) rotateY(${springProps.rotateY.get()}deg) rotateZ(${springProps.rotateZ.get()}deg)`
|
||||
),
|
||||
}}
|
||||
>
|
||||
{/* Front face (1) */}
|
||||
<div style={{ ...faceStyle, transform: `translateZ(${halfSize}px)` }}>{renderDots(1)}</div>
|
||||
{/* Back face (6) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateY(180deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(6)}
|
||||
</div>
|
||||
{/* Right face (2) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateY(90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(2)}
|
||||
</div>
|
||||
{/* Left face (5) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateY(-90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(5)}
|
||||
</div>
|
||||
{/* Top face (3) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateX(90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(3)}
|
||||
</div>
|
||||
{/* Bottom face (4) */}
|
||||
<div
|
||||
style={{
|
||||
...faceStyle,
|
||||
transform: `rotateX(-90deg) translateZ(${halfSize}px)`,
|
||||
}}
|
||||
>
|
||||
{renderDots(4)}
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface InteractiveDiceProps {
|
||||
/** Called when the dice is rolled (clicked or thrown) - fires immediately */
|
||||
onRoll: () => void
|
||||
/** Called when dragging starts - useful for preloading/prefetching */
|
||||
onDragStart?: () => void
|
||||
/** Called when the dice animation completes and settles back home */
|
||||
onRollComplete?: () => void
|
||||
/** Whether the dice is disabled */
|
||||
disabled?: boolean
|
||||
/** Size of the dice in pixels (default: 22) */
|
||||
size?: number
|
||||
/** Title/tooltip for the dice button */
|
||||
title?: string
|
||||
/** Additional CSS class for the button */
|
||||
className?: string
|
||||
/** Button style overrides */
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive 3D dice that can be clicked or dragged and thrown.
|
||||
*
|
||||
* Features:
|
||||
* - Click to roll
|
||||
* - Drag and release to throw (physics simulation)
|
||||
* - Bounces off viewport edges
|
||||
* - Grows when flying, shrinks when returning
|
||||
* - Smooth spring-based animations
|
||||
*/
|
||||
export function InteractiveDice({
|
||||
onRoll,
|
||||
onDragStart,
|
||||
onRollComplete,
|
||||
disabled = false,
|
||||
size = 22,
|
||||
title = 'Roll dice (drag or click)',
|
||||
className,
|
||||
style,
|
||||
}: InteractiveDiceProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Dice rotation state for react-spring animation
|
||||
// We track cumulative spins to add visual flair, then compute final rotation
|
||||
// to land on the correct face derived from a random value
|
||||
const [spinCount, setSpinCount] = useState(0)
|
||||
// Track which face we're showing (for ensuring consecutive rolls differ)
|
||||
const [currentFace, setCurrentFace] = useState(() => Math.floor(Math.random() * 5) + 2)
|
||||
|
||||
// Draggable dice state with physics simulation
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [diceOrigin, setDiceOrigin] = useState({ x: 0, y: 0 })
|
||||
const diceButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const diceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const dragStartPos = useRef({ x: 0, y: 0 })
|
||||
|
||||
// Physics state for thrown dice
|
||||
const [isFlying, setIsFlying] = useState(false)
|
||||
const dicePhysics = useRef({
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
scale: 1, // Grows to 3x when flying, shrinks back when settling
|
||||
})
|
||||
const lastPointerPos = useRef({ x: 0, y: 0, time: 0 })
|
||||
// Track velocity samples for smoother flick detection
|
||||
const velocitySamples = useRef<Array<{ vx: number; vy: number; time: number }>>([])
|
||||
const animationFrameRef = useRef<number>()
|
||||
// Timeout ref for click-only roll completion callback
|
||||
const rollCompleteTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
// Flag to prevent click from firing after a throw (pointerup + click both fire)
|
||||
const justThrewRef = useRef(false)
|
||||
|
||||
// Ref to the portal dice element for direct DOM manipulation during drag/flying (avoids re-renders)
|
||||
const portalDiceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Compute target rotation for the current face (needed by physics simulation)
|
||||
const targetFaceRotation = DICE_FACE_ROTATIONS[currentFace] || {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
}
|
||||
|
||||
// Roll the dice - animate and call onRoll
|
||||
// isFromThrow: true if this roll is from a drag-throw (physics simulation will handle completion)
|
||||
const handleRoll = useCallback(
|
||||
(isFromThrow = false) => {
|
||||
// Clear any pending completion timeout from previous click roll
|
||||
if (rollCompleteTimeoutRef.current) {
|
||||
clearTimeout(rollCompleteTimeoutRef.current)
|
||||
rollCompleteTimeoutRef.current = undefined
|
||||
}
|
||||
|
||||
// Calculate target face (2-6, excluding 1 so it's clearly a dice)
|
||||
const baseFace = Math.floor(Math.random() * 5) + 2
|
||||
|
||||
// Ensure it's different from the current face
|
||||
const targetFace = baseFace === currentFace ? (baseFace === 6 ? 2 : baseFace + 1) : baseFace
|
||||
|
||||
// Add 1-2 full spins for visual drama
|
||||
const extraSpins = Math.floor(Math.random() * 2) + 1
|
||||
|
||||
setSpinCount((prev) => prev + extraSpins)
|
||||
setCurrentFace(targetFace)
|
||||
onRoll()
|
||||
|
||||
// For click-only rolls (not throws), schedule completion callback after spring animation
|
||||
// Spring config is tension: 120, friction: 14, which settles in ~600-700ms
|
||||
if (!isFromThrow && onRollComplete) {
|
||||
rollCompleteTimeoutRef.current = setTimeout(() => {
|
||||
onRollComplete()
|
||||
rollCompleteTimeoutRef.current = undefined
|
||||
}, 700)
|
||||
}
|
||||
},
|
||||
[currentFace, onRoll, onRollComplete]
|
||||
)
|
||||
|
||||
// Physics simulation for thrown dice - uses direct DOM manipulation for performance
|
||||
useEffect(() => {
|
||||
if (!isFlying) return
|
||||
|
||||
const BASE_GRAVITY = 0.8 // Base pull toward origin
|
||||
const BASE_FRICTION = 0.94 // Base velocity dampening (slightly less friction for longer flight)
|
||||
const ROTATION_FACTOR = 0.5 // How much velocity affects rotation
|
||||
const STOP_THRESHOLD = 2 // Distance threshold to snap home
|
||||
const VELOCITY_THRESHOLD = 0.5 // Velocity threshold to snap home
|
||||
const CLOSE_RANGE = 40 // Distance at which extra damping kicks in
|
||||
const MAX_SCALE = 3 // Maximum scale when flying
|
||||
const SCALE_GROW_SPEED = 0.2 // How fast to grow (faster)
|
||||
const SCALE_SHRINK_SPEED = 0.06 // How fast to shrink when close (slower for drama)
|
||||
const BOUNCE_DAMPING = 0.7 // How much velocity is retained on bounce (0-1)
|
||||
const DICE_SIZE = size // Size of the dice in pixels
|
||||
|
||||
// Calculate initial throw power to adjust gravity (stronger throws = weaker initial gravity)
|
||||
const initialSpeed = Math.sqrt(
|
||||
dicePhysics.current.vx * dicePhysics.current.vx +
|
||||
dicePhysics.current.vy * dicePhysics.current.vy
|
||||
)
|
||||
const throwPower = Math.min(initialSpeed / 20, 1) // 0-1 based on throw strength
|
||||
|
||||
let frameCount = 0
|
||||
|
||||
const animate = () => {
|
||||
const p = dicePhysics.current
|
||||
const el = portalDiceRef.current
|
||||
if (!el) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
|
||||
frameCount++
|
||||
|
||||
// Calculate distance to origin
|
||||
const dist = Math.sqrt(p.x * p.x + p.y * p.y)
|
||||
|
||||
// Gravity ramps up over time (weak at first for strong throws, then strengthens)
|
||||
const gravityRampUp = Math.min(frameCount / 30, 1) // Full gravity after ~0.5s
|
||||
const effectiveGravity = BASE_GRAVITY * (0.3 + 0.7 * gravityRampUp) * (1 - throwPower * 0.5)
|
||||
|
||||
// Apply spring force toward origin (proportional to distance)
|
||||
if (dist > 0) {
|
||||
// Quadratic falloff for more natural feel
|
||||
const pullStrength = effectiveGravity * (dist / 50) ** 1.2
|
||||
p.vx += (-p.x / dist) * pullStrength
|
||||
p.vy += (-p.y / dist) * pullStrength
|
||||
}
|
||||
|
||||
// Apply friction - extra damping when close to prevent oscillation
|
||||
const friction = dist < CLOSE_RANGE ? 0.88 : BASE_FRICTION
|
||||
p.vx *= friction
|
||||
p.vy *= friction
|
||||
|
||||
// Update position
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
|
||||
// Viewport edge bounce - calculate absolute position and check bounds
|
||||
const scaledSize = DICE_SIZE * p.scale
|
||||
const absoluteX = diceOrigin.x + p.x
|
||||
const absoluteY = diceOrigin.y + p.y
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// Left edge bounce
|
||||
if (absoluteX < 0) {
|
||||
p.x = -diceOrigin.x // Position at left edge
|
||||
p.vx = Math.abs(p.vx) * BOUNCE_DAMPING // Reverse and dampen
|
||||
// Add extra spin on bounce
|
||||
p.rotationZ += p.vx * 5
|
||||
}
|
||||
// Right edge bounce
|
||||
if (absoluteX + scaledSize > viewportWidth) {
|
||||
p.x = viewportWidth - diceOrigin.x - scaledSize
|
||||
p.vx = -Math.abs(p.vx) * BOUNCE_DAMPING
|
||||
p.rotationZ -= p.vx * 5
|
||||
}
|
||||
// Top edge bounce
|
||||
if (absoluteY < 0) {
|
||||
p.y = -diceOrigin.y
|
||||
p.vy = Math.abs(p.vy) * BOUNCE_DAMPING
|
||||
p.rotationZ += p.vy * 5
|
||||
}
|
||||
// Bottom edge bounce
|
||||
if (absoluteY + scaledSize > viewportHeight) {
|
||||
p.y = viewportHeight - diceOrigin.y - scaledSize
|
||||
p.vy = -Math.abs(p.vy) * BOUNCE_DAMPING
|
||||
p.rotationZ -= p.vy * 5
|
||||
}
|
||||
|
||||
// Update rotation based on velocity (dice rolls as it moves)
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
|
||||
|
||||
// As dice gets closer to home, gradually lerp rotation toward final face
|
||||
// settleProgress: 0 = far away (full physics rotation), 1 = at home (target rotation)
|
||||
const SETTLE_START_DIST = 150 // Start settling rotation at this distance
|
||||
const settleProgress = Math.max(0, 1 - dist / SETTLE_START_DIST)
|
||||
const settleFactor = settleProgress * settleProgress * settleProgress // Cubic easing for smooth settle
|
||||
|
||||
// Physics rotation (tumbling)
|
||||
const physicsRotationDelta = {
|
||||
x: p.vy * ROTATION_FACTOR * 12,
|
||||
y: -p.vx * ROTATION_FACTOR * 12,
|
||||
z: speed * ROTATION_FACTOR * 3,
|
||||
}
|
||||
|
||||
// Apply physics rotation, but reduced as we settle
|
||||
p.rotationX += physicsRotationDelta.x * (1 - settleFactor)
|
||||
p.rotationY += physicsRotationDelta.y * (1 - settleFactor)
|
||||
p.rotationZ += physicsRotationDelta.z * (1 - settleFactor)
|
||||
|
||||
// Lerp toward target face rotation as we settle
|
||||
// Target rotation should show the correct face (from DICE_FACE_ROTATIONS)
|
||||
const lerpSpeed = 0.08 * settleFactor // Faster lerp as we get closer
|
||||
if (settleFactor > 0.01) {
|
||||
// Normalize rotations to find shortest path to target
|
||||
const targetX = targetFaceRotation.rotateX
|
||||
const targetY = targetFaceRotation.rotateY
|
||||
const targetZ = 0 // Final Z rotation should be 0 (flat)
|
||||
|
||||
// Normalize current rotation to -180 to 180 range for smooth interpolation
|
||||
const normalizeAngle = (angle: number) => {
|
||||
let normalized = angle % 360
|
||||
if (normalized > 180) normalized -= 360
|
||||
if (normalized < -180) normalized += 360
|
||||
return normalized
|
||||
}
|
||||
|
||||
const currentX = normalizeAngle(p.rotationX)
|
||||
const currentY = normalizeAngle(p.rotationY)
|
||||
const currentZ = normalizeAngle(p.rotationZ)
|
||||
|
||||
// Lerp each axis toward target
|
||||
p.rotationX = currentX + (targetX - currentX) * lerpSpeed
|
||||
p.rotationY = currentY + (targetY - currentY) * lerpSpeed
|
||||
p.rotationZ = currentZ + (targetZ - currentZ) * lerpSpeed
|
||||
}
|
||||
|
||||
// Update scale - grow when far/fast, shrink when close/slow
|
||||
const targetScale =
|
||||
dist > CLOSE_RANGE ? MAX_SCALE : 1 + ((MAX_SCALE - 1) * dist) / CLOSE_RANGE
|
||||
if (p.scale < targetScale) {
|
||||
p.scale = Math.min(p.scale + SCALE_GROW_SPEED, targetScale)
|
||||
} else if (p.scale > targetScale) {
|
||||
p.scale = Math.max(p.scale - SCALE_SHRINK_SPEED, targetScale)
|
||||
}
|
||||
|
||||
// Update DOM directly - no React re-renders
|
||||
// Scale from center, offset position to keep visual center stable
|
||||
const scaleOffset = ((p.scale - 1) * DICE_SIZE) / 2
|
||||
el.style.transform = `translate(${p.x - scaleOffset}px, ${p.y - scaleOffset}px) scale(${p.scale})`
|
||||
|
||||
// Dynamic shadow based on scale (larger = higher = bigger shadow)
|
||||
const shadowSize = (p.scale - 1) * 10
|
||||
const shadowOpacity = Math.min((p.scale - 1) * 0.2, 0.4)
|
||||
el.style.filter =
|
||||
shadowSize > 0
|
||||
? `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
|
||||
: 'none'
|
||||
|
||||
// Update dice rotation
|
||||
const diceEl = el.querySelector('[data-dice-cube]') as HTMLElement | null
|
||||
if (diceEl) {
|
||||
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
|
||||
}
|
||||
|
||||
// Check if we should stop - snap to home when close, slow, small, AND rotation is settled
|
||||
const totalVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
|
||||
const rotationSettled =
|
||||
Math.abs(p.rotationX - targetFaceRotation.rotateX) < 5 &&
|
||||
Math.abs(p.rotationY - targetFaceRotation.rotateY) < 5 &&
|
||||
Math.abs(p.rotationZ) < 5
|
||||
|
||||
if (
|
||||
dist < STOP_THRESHOLD &&
|
||||
totalVelocity < VELOCITY_THRESHOLD &&
|
||||
p.scale < 1.1 &&
|
||||
rotationSettled
|
||||
) {
|
||||
// Dice has returned home - clear shadow
|
||||
el.style.filter = 'none'
|
||||
setIsFlying(false)
|
||||
dicePhysics.current = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: targetFaceRotation.rotateX,
|
||||
rotationY: targetFaceRotation.rotateY,
|
||||
rotationZ: 0,
|
||||
scale: 1,
|
||||
}
|
||||
// Notify that roll animation is complete
|
||||
onRollComplete?.()
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [isFlying, diceOrigin.x, diceOrigin.y, targetFaceRotation.rotateX, targetFaceRotation.rotateY, size, onRollComplete])
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rollCompleteTimeoutRef.current) {
|
||||
clearTimeout(rollCompleteTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Compute dice rotation for react-spring animation (used when not flying)
|
||||
const diceRotation = {
|
||||
rotateX: spinCount * 360 + targetFaceRotation.rotateX,
|
||||
rotateY: spinCount * 360 + targetFaceRotation.rotateY,
|
||||
rotateZ: spinCount * 180, // Z rotation for extra tumble effect
|
||||
}
|
||||
|
||||
// Dice drag handlers for the easter egg - drag dice off and release to roll
|
||||
const handleDicePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Cancel any ongoing physics animation
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
setIsFlying(false)
|
||||
|
||||
// Capture the pointer
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
// Get the dice container's position for portal rendering
|
||||
if (diceContainerRef.current) {
|
||||
const rect = diceContainerRef.current.getBoundingClientRect()
|
||||
setDiceOrigin({ x: rect.left, y: rect.top })
|
||||
}
|
||||
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY }
|
||||
lastPointerPos.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
time: performance.now(),
|
||||
}
|
||||
velocitySamples.current = [] // Reset velocity tracking
|
||||
dicePhysics.current = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
scale: 1,
|
||||
}
|
||||
setIsDragging(true)
|
||||
// Notify that drag started - useful for prefetching
|
||||
onDragStart?.()
|
||||
},
|
||||
[disabled, onDragStart]
|
||||
)
|
||||
|
||||
const handleDicePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
const dx = e.clientX - dragStartPos.current.x
|
||||
const dy = e.clientY - dragStartPos.current.y
|
||||
|
||||
// Calculate drag velocity for live rotation
|
||||
const now = performance.now()
|
||||
const dt = Math.max(now - lastPointerPos.current.time, 8)
|
||||
const vx = (e.clientX - lastPointerPos.current.x) / dt
|
||||
const vy = (e.clientY - lastPointerPos.current.y) / dt
|
||||
|
||||
// Update rotation based on drag velocity (dice tumbles while being dragged)
|
||||
const p = dicePhysics.current
|
||||
p.rotationX += vy * 8
|
||||
p.rotationY -= vx * 8
|
||||
p.rotationZ += Math.sqrt(vx * vx + vy * vy) * 2
|
||||
|
||||
// Scale up slightly based on distance (feels like pulling it out)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
p.scale = 1 + Math.min(dist / 150, 0.5) // Max 1.5x during drag
|
||||
|
||||
// Update DOM directly to avoid React re-renders during drag
|
||||
if (portalDiceRef.current) {
|
||||
const scaleOffset = ((p.scale - 1) * size) / 2
|
||||
portalDiceRef.current.style.transform = `translate(${dx - scaleOffset}px, ${dy - scaleOffset}px) scale(${p.scale})`
|
||||
// Add shadow that grows with distance
|
||||
const shadowSize = Math.min(dist / 10, 20)
|
||||
const shadowOpacity = Math.min(dist / 200, 0.4)
|
||||
portalDiceRef.current.style.filter = `drop-shadow(0 ${shadowSize}px ${shadowSize * 1.5}px rgba(0,0,0,${shadowOpacity}))`
|
||||
|
||||
// Update dice rotation
|
||||
const diceEl = portalDiceRef.current.querySelector('[data-dice-cube]') as HTMLElement | null
|
||||
if (diceEl) {
|
||||
diceEl.style.transform = `rotateX(${p.rotationX}deg) rotateY(${p.rotationY}deg) rotateZ(${p.rotationZ}deg)`
|
||||
}
|
||||
}
|
||||
|
||||
// Store position in ref for use when releasing
|
||||
p.x = dx
|
||||
p.y = dy
|
||||
|
||||
// Track velocity samples for flick detection (keep last 5 samples, ~80ms window)
|
||||
velocitySamples.current.push({ vx, vy, time: now })
|
||||
if (velocitySamples.current.length > 5) {
|
||||
velocitySamples.current.shift()
|
||||
}
|
||||
|
||||
// Track velocity for throw calculation
|
||||
lastPointerPos.current = { x: e.clientX, y: e.clientY, time: now }
|
||||
},
|
||||
[isDragging, size]
|
||||
)
|
||||
|
||||
const handleDicePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
|
||||
// Release the pointer
|
||||
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
|
||||
// Calculate throw velocity from velocity samples (average of recent samples for smooth flick)
|
||||
const samples = velocitySamples.current
|
||||
let vx = 0
|
||||
let vy = 0
|
||||
|
||||
if (samples.length > 0) {
|
||||
// Weight recent samples more heavily
|
||||
let totalWeight = 0
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const weight = i + 1 // Later samples get higher weight
|
||||
vx += samples[i].vx * weight
|
||||
vy += samples[i].vy * weight
|
||||
totalWeight += weight
|
||||
}
|
||||
vx /= totalWeight
|
||||
vy /= totalWeight
|
||||
}
|
||||
|
||||
// Get position from physics ref (set during drag)
|
||||
const posX = dicePhysics.current.x
|
||||
const posY = dicePhysics.current.y
|
||||
|
||||
// Calculate distance dragged
|
||||
const distance = Math.sqrt(posX ** 2 + posY ** 2)
|
||||
|
||||
// Calculate flick speed
|
||||
const flickSpeed = Math.sqrt(vx * vx + vy * vy)
|
||||
|
||||
// If dragged more than 20px OR flicked fast enough, trigger throw physics
|
||||
if (distance > 20 || flickSpeed > 0.3) {
|
||||
// Amplify throw velocity significantly for satisfying flick
|
||||
const throwMultiplier = 25 // Much stronger throw!
|
||||
|
||||
// Initialize physics with current position and throw velocity
|
||||
dicePhysics.current = {
|
||||
x: posX,
|
||||
y: posY,
|
||||
vx: vx * throwMultiplier,
|
||||
vy: vy * throwMultiplier,
|
||||
rotationX: dicePhysics.current.rotationX, // Keep current rotation
|
||||
rotationY: dicePhysics.current.rotationY,
|
||||
rotationZ: dicePhysics.current.rotationZ,
|
||||
scale: dicePhysics.current.scale, // Keep current scale
|
||||
}
|
||||
setIsFlying(true)
|
||||
// Mark that we just threw - prevents click event from also firing handleRoll
|
||||
justThrewRef.current = true
|
||||
// Trigger roll when thrown (pass true to indicate physics will handle completion)
|
||||
handleRoll(true)
|
||||
} else {
|
||||
// Not thrown far enough, snap back
|
||||
dicePhysics.current = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
scale: 1,
|
||||
}
|
||||
}
|
||||
|
||||
setIsDragging(false)
|
||||
},
|
||||
[isDragging, handleRoll]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={diceButtonRef}
|
||||
type="button"
|
||||
data-action="roll-dice"
|
||||
onClick={() => {
|
||||
// Skip if we just did a throw (pointerup already called handleRoll)
|
||||
if (justThrewRef.current) {
|
||||
justThrewRef.current = false
|
||||
return
|
||||
}
|
||||
handleRoll()
|
||||
}}
|
||||
onPointerDown={handleDicePointerDown}
|
||||
onPointerMove={handleDicePointerMove}
|
||||
onPointerUp={handleDicePointerUp}
|
||||
onPointerCancel={handleDicePointerUp}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={className}
|
||||
style={{
|
||||
cursor: isDragging ? 'grabbing' : disabled ? 'not-allowed' : 'grab',
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
touchAction: 'none', // Prevent scroll on touch devices
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{/* Dice container - hidden when dragging/flying since we show portal version */}
|
||||
<div
|
||||
ref={diceContainerRef}
|
||||
style={{
|
||||
opacity: isDragging || isFlying ? 0 : 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<DiceIcon
|
||||
rotateX={diceRotation.rotateX}
|
||||
rotateY={diceRotation.rotateY}
|
||||
rotateZ={diceRotation.rotateZ}
|
||||
isDark={isDark}
|
||||
size={size}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Portal-rendered dice when dragging/flying - renders outside button to avoid clipping */}
|
||||
{/* All transforms are applied directly via ref for performance - no React re-renders */}
|
||||
{(isDragging || isFlying) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={portalDiceRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: diceOrigin.x,
|
||||
top: diceOrigin.y,
|
||||
// Transform is updated directly via ref during drag/flying
|
||||
transform: 'translate(0px, 0px)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100000,
|
||||
cursor: isDragging ? 'grabbing' : 'default',
|
||||
willChange: 'transform', // Hint to browser for GPU acceleration
|
||||
}}
|
||||
>
|
||||
{/* During flying, rotation is updated directly on data-dice-cube element */}
|
||||
<DiceIcon
|
||||
rotateX={diceRotation.rotateX}
|
||||
rotateY={diceRotation.rotateY}
|
||||
rotateZ={diceRotation.rotateZ}
|
||||
isDark={isDark}
|
||||
size={size}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
434
apps/web/src/lib/flowcharts/constraint-parser.ts
Normal file
434
apps/web/src/lib/flowcharts/constraint-parser.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Constraint Parser
|
||||
*
|
||||
* Parses correctAnswer expressions from flowchart decision nodes into
|
||||
* structured constraints that can guide problem generation.
|
||||
*
|
||||
* Recognizes patterns like:
|
||||
* - "a == b" (equality)
|
||||
* - "a != b" (inequality)
|
||||
* - "a > b", "a >= b", "a < b", "a <= b" (comparison)
|
||||
* - "a % b == 0" (divisibility)
|
||||
* - "a == 0", "a != 0" (zero checks)
|
||||
* - Boolean expressions with && and ||
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type ComparisonOp = '==' | '!=' | '>' | '>=' | '<' | '<='
|
||||
|
||||
/**
|
||||
* A parsed constraint representing a comparison between two operands.
|
||||
*/
|
||||
export interface ParsedConstraint {
|
||||
/** Type of constraint */
|
||||
type: 'comparison' | 'divisibility' | 'boolean'
|
||||
|
||||
/** Left operand (field name or expression) */
|
||||
left: string
|
||||
|
||||
/** Comparison operator */
|
||||
op: ComparisonOp
|
||||
|
||||
/** Right operand (field name, literal, or expression) */
|
||||
right: string
|
||||
|
||||
/**
|
||||
* Whether the right side is a literal value.
|
||||
* If true, we can directly constrain the left field.
|
||||
*/
|
||||
rightIsLiteral: boolean
|
||||
|
||||
/**
|
||||
* The literal value if rightIsLiteral is true.
|
||||
*/
|
||||
literalValue?: number | string | boolean
|
||||
|
||||
/**
|
||||
* For divisibility constraints (a % b == 0), the divisor field.
|
||||
*/
|
||||
divisor?: string
|
||||
|
||||
/**
|
||||
* The original expression string.
|
||||
*/
|
||||
original: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing a correctAnswer expression.
|
||||
*/
|
||||
export interface ParseResult {
|
||||
/** Successfully parsed constraints */
|
||||
constraints: ParsedConstraint[]
|
||||
|
||||
/** Whether the expression was fully parsed */
|
||||
fullyParsed: boolean
|
||||
|
||||
/** Original expression */
|
||||
original: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Parser
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse a correctAnswer expression into structured constraints.
|
||||
*
|
||||
* @param expression The correctAnswer expression string
|
||||
* @returns ParseResult with extracted constraints
|
||||
*/
|
||||
export function parseConstraintExpression(expression: string): ParseResult {
|
||||
const constraints: ParsedConstraint[] = []
|
||||
const original = expression.trim()
|
||||
|
||||
// Handle simple boolean literals
|
||||
if (original === 'true' || original === 'false') {
|
||||
return {
|
||||
constraints: [{
|
||||
type: 'boolean',
|
||||
left: original,
|
||||
op: '==',
|
||||
right: 'true',
|
||||
rightIsLiteral: true,
|
||||
literalValue: original === 'true',
|
||||
original,
|
||||
}],
|
||||
fullyParsed: true,
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as a single comparison
|
||||
const singleConstraint = parseSingleComparison(original)
|
||||
if (singleConstraint) {
|
||||
constraints.push(singleConstraint)
|
||||
return { constraints, fullyParsed: true, original }
|
||||
}
|
||||
|
||||
// Try to parse && expressions (all must be true)
|
||||
if (original.includes('&&')) {
|
||||
const parts = splitOnOperator(original, '&&')
|
||||
let allParsed = true
|
||||
|
||||
for (const part of parts) {
|
||||
const constraint = parseSingleComparison(part.trim())
|
||||
if (constraint) {
|
||||
constraints.push(constraint)
|
||||
} else {
|
||||
allParsed = false
|
||||
}
|
||||
}
|
||||
|
||||
return { constraints, fullyParsed: allParsed, original }
|
||||
}
|
||||
|
||||
// Try to parse || expressions (any can be true)
|
||||
// For generation, we'll pick one branch to satisfy
|
||||
if (original.includes('||')) {
|
||||
const parts = splitOnOperator(original, '||')
|
||||
|
||||
// For OR expressions, we parse all branches but mark as not fully parsed
|
||||
// since the generator needs to decide which branch to take
|
||||
for (const part of parts) {
|
||||
const constraint = parseSingleComparison(part.trim())
|
||||
if (constraint) {
|
||||
constraints.push(constraint)
|
||||
}
|
||||
}
|
||||
|
||||
return { constraints, fullyParsed: false, original }
|
||||
}
|
||||
|
||||
// Couldn't parse - return empty constraints
|
||||
return { constraints: [], fullyParsed: false, original }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single comparison expression like "a == b" or "a != 0".
|
||||
*/
|
||||
function parseSingleComparison(expr: string): ParsedConstraint | null {
|
||||
const trimmed = expr.trim()
|
||||
|
||||
// Remove outer parentheses if present
|
||||
const unwrapped = unwrapParens(trimmed)
|
||||
|
||||
// Check for divisibility pattern: a % b == 0
|
||||
const divMatch = unwrapped.match(/^(.+?)\s*%\s*(.+?)\s*==\s*0$/)
|
||||
if (divMatch) {
|
||||
return {
|
||||
type: 'divisibility',
|
||||
left: divMatch[1].trim(),
|
||||
op: '==',
|
||||
right: '0',
|
||||
rightIsLiteral: true,
|
||||
literalValue: 0,
|
||||
divisor: divMatch[2].trim(),
|
||||
original: expr,
|
||||
}
|
||||
}
|
||||
|
||||
// Check for comparison operators (order matters - check >= before >)
|
||||
const ops: ComparisonOp[] = ['==', '!=', '>=', '<=', '>', '<']
|
||||
|
||||
for (const op of ops) {
|
||||
const parts = splitOnOperator(unwrapped, op)
|
||||
if (parts.length === 2) {
|
||||
const left = parts[0].trim()
|
||||
const right = parts[1].trim()
|
||||
|
||||
// Check if right side is a literal
|
||||
const literalValue = parseLiteral(right)
|
||||
const rightIsLiteral = literalValue !== undefined
|
||||
|
||||
return {
|
||||
type: 'comparison',
|
||||
left,
|
||||
op,
|
||||
right,
|
||||
rightIsLiteral,
|
||||
literalValue,
|
||||
original: expr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an expression on an operator, respecting parentheses.
|
||||
*/
|
||||
function splitOnOperator(expr: string, operator: string): string[] {
|
||||
const result: string[] = []
|
||||
let current = ''
|
||||
let parenDepth = 0
|
||||
let i = 0
|
||||
|
||||
while (i < expr.length) {
|
||||
const char = expr[i]
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth++
|
||||
current += char
|
||||
i++
|
||||
} else if (char === ')') {
|
||||
parenDepth--
|
||||
current += char
|
||||
i++
|
||||
} else if (parenDepth === 0 && expr.substring(i, i + operator.length) === operator) {
|
||||
result.push(current)
|
||||
current = ''
|
||||
i += operator.length
|
||||
} else {
|
||||
current += char
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
result.push(current)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove outer parentheses from an expression.
|
||||
*/
|
||||
function unwrapParens(expr: string): string {
|
||||
const trimmed = expr.trim()
|
||||
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
|
||||
// Check if the parens actually wrap the whole expression
|
||||
let depth = 0
|
||||
for (let i = 0; i < trimmed.length - 1; i++) {
|
||||
if (trimmed[i] === '(') depth++
|
||||
if (trimmed[i] === ')') depth--
|
||||
if (depth === 0) return trimmed // Parens don't wrap whole thing
|
||||
}
|
||||
return unwrapParens(trimmed.slice(1, -1))
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a string as a literal value.
|
||||
*/
|
||||
function parseLiteral(value: string): number | string | boolean | undefined {
|
||||
const trimmed = value.trim()
|
||||
|
||||
// Boolean
|
||||
if (trimmed === 'true') return true
|
||||
if (trimmed === 'false') return false
|
||||
|
||||
// Number
|
||||
const num = parseFloat(trimmed)
|
||||
if (!isNaN(num) && isFinite(num) && String(num) === trimmed) {
|
||||
return num
|
||||
}
|
||||
|
||||
// Integer (handles cases like "0" that parseFloat would also handle)
|
||||
const int = parseInt(trimmed, 10)
|
||||
if (!isNaN(int) && String(int) === trimmed) {
|
||||
return int
|
||||
}
|
||||
|
||||
// String literal (quoted)
|
||||
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constraint Analysis
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract field names referenced in a constraint.
|
||||
*/
|
||||
export function extractFieldsFromConstraint(constraint: ParsedConstraint): string[] {
|
||||
const fields: string[] = []
|
||||
|
||||
// Add left side (could be a field or expression)
|
||||
const leftFields = extractFieldsFromExpression(constraint.left)
|
||||
fields.push(...leftFields)
|
||||
|
||||
// Add right side if not a literal
|
||||
if (!constraint.rightIsLiteral) {
|
||||
const rightFields = extractFieldsFromExpression(constraint.right)
|
||||
fields.push(...rightFields)
|
||||
}
|
||||
|
||||
// Add divisor if present
|
||||
if (constraint.divisor) {
|
||||
const divisorFields = extractFieldsFromExpression(constraint.divisor)
|
||||
fields.push(...divisorFields)
|
||||
}
|
||||
|
||||
return [...new Set(fields)] // Deduplicate
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract field names from an expression string.
|
||||
* Recognizes patterns like: fieldName, obj.field, field1 + field2
|
||||
*/
|
||||
function extractFieldsFromExpression(expr: string): string[] {
|
||||
const fields: string[] = []
|
||||
|
||||
// Match identifiers (including dot notation like left.denom)
|
||||
const identifierRegex = /[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*/g
|
||||
const matches = expr.match(identifierRegex) || []
|
||||
|
||||
for (const match of matches) {
|
||||
// Filter out keywords and function names
|
||||
const keywords = ['true', 'false', 'null', 'undefined', 'floor', 'ceil', 'abs', 'min', 'max', 'lcm', 'gcd']
|
||||
if (!keywords.includes(match)) {
|
||||
fields.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Negate a constraint (for when correctAnswer must be false).
|
||||
*/
|
||||
export function negateConstraint(constraint: ParsedConstraint): ParsedConstraint {
|
||||
const negatedOps: Record<ComparisonOp, ComparisonOp> = {
|
||||
'==': '!=',
|
||||
'!=': '==',
|
||||
'>': '<=',
|
||||
'>=': '<',
|
||||
'<': '>=',
|
||||
'<=': '>',
|
||||
}
|
||||
|
||||
return {
|
||||
...constraint,
|
||||
op: negatedOps[constraint.op],
|
||||
original: `!(${constraint.original})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a constraint can be directly used to filter field values.
|
||||
* Returns the field name and filter function if applicable.
|
||||
*/
|
||||
export interface FieldFilter {
|
||||
field: string
|
||||
filter: (value: number | string) => boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export function constraintToFieldFilter(constraint: ParsedConstraint): FieldFilter | null {
|
||||
// Only handle simple cases where left is a single field and right is a literal
|
||||
if (!constraint.rightIsLiteral) return null
|
||||
if (constraint.literalValue === undefined) return null
|
||||
|
||||
const field = constraint.left
|
||||
const literal = constraint.literalValue
|
||||
|
||||
// Check if left is a simple field (no operators)
|
||||
if (field.includes('+') || field.includes('-') || field.includes('*') || field.includes('/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (constraint.op) {
|
||||
case '==':
|
||||
return {
|
||||
field,
|
||||
filter: (v) => v === literal,
|
||||
description: `${field} must equal ${literal}`,
|
||||
}
|
||||
case '!=':
|
||||
return {
|
||||
field,
|
||||
filter: (v) => v !== literal,
|
||||
description: `${field} must not equal ${literal}`,
|
||||
}
|
||||
case '>':
|
||||
if (typeof literal === 'number') {
|
||||
return {
|
||||
field,
|
||||
filter: (v) => typeof v === 'number' && v > literal,
|
||||
description: `${field} must be greater than ${literal}`,
|
||||
}
|
||||
}
|
||||
break
|
||||
case '>=':
|
||||
if (typeof literal === 'number') {
|
||||
return {
|
||||
field,
|
||||
filter: (v) => typeof v === 'number' && v >= literal,
|
||||
description: `${field} must be at least ${literal}`,
|
||||
}
|
||||
}
|
||||
break
|
||||
case '<':
|
||||
if (typeof literal === 'number') {
|
||||
return {
|
||||
field,
|
||||
filter: (v) => typeof v === 'number' && v < literal,
|
||||
description: `${field} must be less than ${literal}`,
|
||||
}
|
||||
}
|
||||
break
|
||||
case '<=':
|
||||
if (typeof literal === 'number') {
|
||||
return {
|
||||
field,
|
||||
filter: (v) => typeof v === 'number' && v <= literal,
|
||||
description: `${field} must be at most ${literal}`,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -3,6 +3,26 @@
|
||||
"title": "Fraction Addition & Subtraction",
|
||||
"mermaidFile": "fraction-flowchart.mmd",
|
||||
|
||||
"generation": {
|
||||
"preferred": {
|
||||
"leftWhole": [0, 1, 2, 3, 4, 5],
|
||||
"leftNum": [1, 2, 3, 4, 5],
|
||||
"leftDenom": [2, 3, 4, 5, 6, 8, 10, 12],
|
||||
"rightWhole": [0, 1, 2, 3, 4],
|
||||
"rightNum": [1, 2, 3, 4, 5],
|
||||
"rightDenom": [2, 3, 4, 5, 6, 8, 10, 12]
|
||||
}
|
||||
},
|
||||
|
||||
"constraints": {
|
||||
"properLeftFraction": "leftNum < leftDenom",
|
||||
"properRightFraction": "rightNum < rightDenom",
|
||||
"positiveResult": "op == '+' || (leftWhole + leftNum / leftDenom) >= (rightWhole + rightNum / rightDenom)",
|
||||
"notIdentical": "leftWhole != rightWhole || leftNum != rightNum || leftDenom != rightDenom",
|
||||
"meaningfulSubtraction": "op == '+' || (leftWhole + leftNum / leftDenom) - (rightWhole + rightNum / rightDenom) >= 0.25",
|
||||
"nonTrivialNumerators": "leftNum > 0 && rightNum > 0"
|
||||
},
|
||||
|
||||
"problemInput": {
|
||||
"schema": "two-fractions-with-op",
|
||||
"fields": [
|
||||
@@ -14,7 +34,59 @@
|
||||
{ "name": "rightNum", "type": "integer", "min": 0, "max": 99, "label": "Right numerator" },
|
||||
{ "name": "rightDenom", "type": "integer", "min": 1, "max": 99, "label": "Right denominator" }
|
||||
],
|
||||
"validation": "leftDenom > 0 && rightDenom > 0"
|
||||
"validation": "leftDenom > 0 && rightDenom > 0",
|
||||
"examples": [
|
||||
{
|
||||
"name": "Same denom + add (simple)",
|
||||
"description": "Denominators match, no whole numbers",
|
||||
"values": { "leftWhole": 0, "leftNum": 1, "leftDenom": 4, "op": "+", "rightWhole": 0, "rightNum": 2, "rightDenom": 4 }
|
||||
},
|
||||
{
|
||||
"name": "Same denom + add (mixed)",
|
||||
"description": "Denominators match, with whole numbers",
|
||||
"values": { "leftWhole": 2, "leftNum": 1, "leftDenom": 5, "op": "+", "rightWhole": 1, "rightNum": 2, "rightDenom": 5 }
|
||||
},
|
||||
{
|
||||
"name": "Same denom + sub (no borrow)",
|
||||
"description": "Denominators match, top fraction bigger",
|
||||
"values": { "leftWhole": 3, "leftNum": 3, "leftDenom": 4, "op": "−", "rightWhole": 1, "rightNum": 1, "rightDenom": 4 }
|
||||
},
|
||||
{
|
||||
"name": "Same denom + sub (borrow)",
|
||||
"description": "Denominators match, need to borrow",
|
||||
"values": { "leftWhole": 3, "leftNum": 1, "leftDenom": 4, "op": "−", "rightWhole": 1, "rightNum": 3, "rightDenom": 4 }
|
||||
},
|
||||
{
|
||||
"name": "One divides + add (simple)",
|
||||
"description": "4 divides 8, no whole numbers",
|
||||
"values": { "leftWhole": 0, "leftNum": 1, "leftDenom": 4, "op": "+", "rightWhole": 0, "rightNum": 3, "rightDenom": 8 }
|
||||
},
|
||||
{
|
||||
"name": "One divides + add (mixed)",
|
||||
"description": "4 divides 8, with whole numbers",
|
||||
"values": { "leftWhole": 2, "leftNum": 1, "leftDenom": 4, "op": "+", "rightWhole": 1, "rightNum": 3, "rightDenom": 8 }
|
||||
},
|
||||
{
|
||||
"name": "One divides + sub (borrow)",
|
||||
"description": "3 divides 6, need to borrow",
|
||||
"values": { "leftWhole": 3, "leftNum": 1, "leftDenom": 6, "op": "−", "rightWhole": 1, "rightNum": 2, "rightDenom": 3 }
|
||||
},
|
||||
{
|
||||
"name": "LCD + add (simple)",
|
||||
"description": "Find LCD=12, no whole numbers",
|
||||
"values": { "leftWhole": 0, "leftNum": 1, "leftDenom": 3, "op": "+", "rightWhole": 0, "rightNum": 1, "rightDenom": 4 }
|
||||
},
|
||||
{
|
||||
"name": "LCD + add (mixed)",
|
||||
"description": "Find LCD=12, with whole numbers",
|
||||
"values": { "leftWhole": 2, "leftNum": 2, "leftDenom": 3, "op": "+", "rightWhole": 1, "rightNum": 3, "rightDenom": 4 }
|
||||
},
|
||||
{
|
||||
"name": "LCD + sub (borrow)",
|
||||
"description": "Find LCD=12 and borrow",
|
||||
"values": { "leftWhole": 4, "leftNum": 1, "leftDenom": 3, "op": "−", "rightWhole": 2, "rightNum": 3, "rightDenom": 4 }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"variables": {
|
||||
@@ -44,8 +116,8 @@
|
||||
"type": "decision",
|
||||
"correctAnswer": "sameBottom",
|
||||
"options": [
|
||||
{ "label": "YES ✓", "value": "yes", "next": "READY1" },
|
||||
{ "label": "NO", "value": "no", "next": "STEP2" }
|
||||
{ "label": "YES ✓", "value": "yes", "next": "READY1", "pathLabel": "Same" },
|
||||
{ "label": "NO", "value": "no", "next": "STEP2", "pathLabel": "Diff" }
|
||||
]
|
||||
},
|
||||
"READY1": {
|
||||
@@ -56,8 +128,8 @@
|
||||
"type": "decision",
|
||||
"correctAnswer": "oneDividesOther",
|
||||
"options": [
|
||||
{ "label": "YES", "value": "yes", "next": "CONV1A" },
|
||||
{ "label": "NO", "value": "no", "next": "STEP3" }
|
||||
{ "label": "YES", "value": "yes", "next": "CONV1A", "pathLabel": "Divides" },
|
||||
{ "label": "NO", "value": "no", "next": "STEP3", "pathLabel": "LCD" }
|
||||
]
|
||||
},
|
||||
"CONV1A": {
|
||||
@@ -112,8 +184,8 @@
|
||||
"type": "decision",
|
||||
"correctAnswer": "!isSubtraction",
|
||||
"options": [
|
||||
{ "label": "➕ Adding", "value": "add", "next": "GOSTEP4" },
|
||||
{ "label": "➖ Subtracting", "value": "sub", "next": "BORROWCHECK" }
|
||||
{ "label": "➕ Adding", "value": "add", "next": "GOSTEP4", "pathLabel": "+" },
|
||||
{ "label": "➖ Subtracting", "value": "sub", "next": "BORROWCHECK", "pathLabel": "−" }
|
||||
]
|
||||
},
|
||||
"GOSTEP4": {
|
||||
@@ -124,8 +196,8 @@
|
||||
"type": "decision",
|
||||
"correctAnswer": "!needsBorrow",
|
||||
"options": [
|
||||
{ "label": "YES ✓ (big enough)", "value": "yes", "next": "GOSTEP4B" },
|
||||
{ "label": "😱 NO! (need borrow)", "value": "no", "next": "BORROW" }
|
||||
{ "label": "YES ✓ (big enough)", "value": "yes", "next": "GOSTEP4B", "pathLabel": "no borrow" },
|
||||
{ "label": "😱 NO! (need borrow)", "value": "no", "next": "BORROW", "pathLabel": "borrow" }
|
||||
]
|
||||
},
|
||||
"GOSTEP4B": {
|
||||
|
||||
@@ -174,7 +174,7 @@ export const FLOWCHARTS: Record<
|
||||
{ definition: FlowchartDefinition; mermaid: string; meta: FlowchartMeta }
|
||||
> = {
|
||||
'subtraction-regrouping': {
|
||||
definition: subtractionDefinition as FlowchartDefinition,
|
||||
definition: subtractionDefinition as unknown as FlowchartDefinition,
|
||||
mermaid: SUBTRACTION_MERMAID,
|
||||
meta: {
|
||||
id: 'subtraction-regrouping',
|
||||
|
||||
@@ -3,6 +3,29 @@
|
||||
"title": "Solving Linear Equations",
|
||||
"mermaidFile": "linear-equations-flowchart.mmd",
|
||||
|
||||
"generation": {
|
||||
"target": "answer",
|
||||
"derived": {
|
||||
"coefficient": "constant == 0 ? 2 + (answer % 5) : 1",
|
||||
"equals": "constant == 0 ? coefficient * answer : (operation == '+' ? answer + constant : answer - constant)"
|
||||
},
|
||||
"preferred": {
|
||||
"answer": [2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
"constant": [0, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
}
|
||||
},
|
||||
|
||||
"constraints": {
|
||||
"positiveAnswer": "answer > 0",
|
||||
"integerAnswer": "floor(answer) == answer",
|
||||
"oneStep": "(constant == 0) != (coefficient == 1)",
|
||||
"positiveEquals": "equals > 0"
|
||||
},
|
||||
|
||||
"display": {
|
||||
"problem": "coefficient == 1 ? 'x ' + operation + ' ' + constant + ' = ' + equals : coefficient + 'x = ' + equals"
|
||||
},
|
||||
|
||||
"problemInput": {
|
||||
"schema": "linear-equation",
|
||||
"fields": [
|
||||
@@ -11,7 +34,24 @@
|
||||
{ "name": "constant", "type": "integer", "min": 0, "max": 99, "label": "Constant" },
|
||||
{ "name": "equals", "type": "integer", "min": 0, "max": 99, "label": "Equals" }
|
||||
],
|
||||
"validation": "coefficient > 0"
|
||||
"validation": "coefficient > 0",
|
||||
"examples": [
|
||||
{
|
||||
"name": "Addition constant",
|
||||
"description": "2x + 4 = 10 → subtract to isolate x term",
|
||||
"values": { "coefficient": 2, "operation": "+", "constant": 4, "equals": 10 }
|
||||
},
|
||||
{
|
||||
"name": "Subtraction constant",
|
||||
"description": "3x − 6 = 9 → add to isolate x term",
|
||||
"values": { "coefficient": 3, "operation": "−", "constant": 6, "equals": 9 }
|
||||
},
|
||||
{
|
||||
"name": "No constant",
|
||||
"description": "4x = 20 → just divide both sides",
|
||||
"values": { "coefficient": 4, "operation": "+", "constant": 0, "equals": 20 }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"variables": {
|
||||
@@ -40,8 +80,8 @@
|
||||
"type": "decision",
|
||||
"correctAnswer": "constant != 0",
|
||||
"options": [
|
||||
{ "label": "ADDED ON (+/−)", "value": "add", "next": "STUCK_ADD" },
|
||||
{ "label": "MULTIPLIED IN (×/÷)", "value": "mul", "next": "STUCK_MUL" }
|
||||
{ "label": "ADDED ON (+/−)", "value": "add", "next": "STUCK_ADD", "pathLabel": "Undo +" },
|
||||
{ "label": "MULTIPLIED IN (×/÷)", "value": "mul", "next": "STUCK_MUL", "pathLabel": "÷" }
|
||||
]
|
||||
},
|
||||
"STUCK_ADD": {
|
||||
|
||||
@@ -3,13 +3,38 @@
|
||||
"title": "Subtraction with Regrouping",
|
||||
"mermaidFile": "subtraction-regrouping-flowchart.mmd",
|
||||
|
||||
"generation": {
|
||||
"preferred": {
|
||||
"minuend": { "range": [30, 89], "step": 1 },
|
||||
"subtrahend": { "range": [11, 49], "step": 1 }
|
||||
}
|
||||
},
|
||||
|
||||
"constraints": {
|
||||
"positiveAnswer": "minuend > subtrahend",
|
||||
"meaningfulDifference": "minuend - subtrahend >= 5",
|
||||
"notTooEasy": "minuend - subtrahend <= minuend - 10"
|
||||
},
|
||||
|
||||
"problemInput": {
|
||||
"schema": "two-digit-subtraction",
|
||||
"fields": [
|
||||
{ "name": "minuend", "type": "integer", "min": 10, "max": 99, "label": "Top number" },
|
||||
{ "name": "subtrahend", "type": "integer", "min": 10, "max": 99, "label": "Bottom number" }
|
||||
],
|
||||
"validation": "minuend > subtrahend"
|
||||
"validation": "minuend > subtrahend",
|
||||
"examples": [
|
||||
{
|
||||
"name": "No regrouping",
|
||||
"description": "Top ones digit is bigger - no borrowing needed",
|
||||
"values": { "minuend": 57, "subtrahend": 23 }
|
||||
},
|
||||
{
|
||||
"name": "With regrouping",
|
||||
"description": "Top ones digit is smaller - need to borrow",
|
||||
"values": { "minuend": 52, "subtrahend": 37 }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"variables": {
|
||||
@@ -38,8 +63,8 @@
|
||||
"type": "decision",
|
||||
"correctAnswer": "!needsBorrow",
|
||||
"options": [
|
||||
{ "label": "YES", "value": "yes", "next": "HAPPY" },
|
||||
{ "label": "NO", "value": "no", "next": "SAD" }
|
||||
{ "label": "YES", "value": "yes", "next": "HAPPY", "pathLabel": "No regroup" },
|
||||
{ "label": "NO", "value": "no", "next": "SAD", "pathLabel": "Regroup" }
|
||||
]
|
||||
},
|
||||
"HAPPY": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,32 +31,33 @@ export function parseNodeContent(raw: string): ParsedNodeContent {
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
const lines = decoded.split(/<br\s*\/?>/i)
|
||||
|
||||
let title = ''
|
||||
const body: string[] = []
|
||||
let example: string | undefined
|
||||
let warning: string | undefined
|
||||
const checklist: string[] = []
|
||||
|
||||
// First, try to extract title from <b>...</b> tags (may span multiple lines via <br/>)
|
||||
// This handles cases like <b>Title Line 1<br/>Title Line 2</b>
|
||||
const boldMatch = decoded.match(/<b>([\s\S]*?)<\/b>/)
|
||||
if (boldMatch) {
|
||||
// Replace <br/> with spaces in the title to make it single line
|
||||
title = stripHtml(boldMatch[1].replace(/<br\s*\/?>/gi, ' '))
|
||||
}
|
||||
|
||||
// Remove the bold section from content before splitting for body processing
|
||||
const contentWithoutTitle = boldMatch
|
||||
? decoded.replace(/<b>[\s\S]*?<\/b>/, '')
|
||||
: decoded
|
||||
|
||||
const lines = contentWithoutTitle.split(/<br\s*\/?>/i)
|
||||
|
||||
let inExample = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
// Extract title from <b>...</b>
|
||||
const boldMatch = trimmed.match(/<b>([^<]+)<\/b>/)
|
||||
if (boldMatch && !title) {
|
||||
title = stripHtml(boldMatch[1])
|
||||
// If there's more content after the title on the same line, add to body
|
||||
const afterBold = trimmed.replace(/<b>[^<]+<\/b>/, '').trim()
|
||||
if (afterBold && !afterBold.match(/^─+$/)) {
|
||||
body.push(stripHtml(afterBold))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip divider lines
|
||||
if (trimmed.match(/^─+$/)) {
|
||||
continue
|
||||
@@ -92,7 +93,8 @@ export function parseNodeContent(raw: string): ParsedNodeContent {
|
||||
}
|
||||
|
||||
return {
|
||||
title: title || stripHtml(raw.split(/<br/i)[0]),
|
||||
// If no <b>...</b> title was found, use the first line as the title
|
||||
title: title || stripHtml(decoded.split(/<br/i)[0]),
|
||||
body,
|
||||
example,
|
||||
warning,
|
||||
|
||||
@@ -57,12 +57,24 @@ export interface DynamicField extends BaseField {
|
||||
|
||||
export type Field = IntegerField | NumberField | ChoiceField | MixedNumberField | DynamicField
|
||||
|
||||
/** An example problem that can be pre-loaded */
|
||||
export interface ProblemExample {
|
||||
/** Display name for the example (e.g., "No regrouping") */
|
||||
name: string
|
||||
/** Description of the path this covers */
|
||||
description?: string
|
||||
/** Values to populate the form with */
|
||||
values: Record<string, ProblemValue>
|
||||
}
|
||||
|
||||
/** Problem input schema definition */
|
||||
export interface ProblemInputSchema {
|
||||
schema: string
|
||||
fields: Field[]
|
||||
/** Optional expression that must evaluate to true for input to be valid */
|
||||
validation?: string
|
||||
/** Pre-defined example problems that cover different paths */
|
||||
examples?: ProblemExample[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -143,6 +155,8 @@ export interface DecisionOption {
|
||||
value: string
|
||||
/** Next node if this option is selected */
|
||||
next: string
|
||||
/** Human-readable label for path descriptors (e.g., "Undo +", "Same denom") */
|
||||
pathLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +203,63 @@ export type FlowchartNode =
|
||||
| MilestoneNode
|
||||
| TerminalNode
|
||||
|
||||
// =============================================================================
|
||||
// Generation Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Preferred values for a field during generation.
|
||||
* Can be an array of specific values or a range configuration.
|
||||
*/
|
||||
export type PreferredValues = number[] | string[] | {
|
||||
range: [number, number]
|
||||
step?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for constraint-guided example generation.
|
||||
* This allows the flowchart to express how to generate pedagogically
|
||||
* appropriate problems without schema-specific code in the engine.
|
||||
*/
|
||||
export interface GenerationConfig {
|
||||
/**
|
||||
* The "target" field - typically the answer we're solving for.
|
||||
* Generated first with nice values, then other fields are derived/generated.
|
||||
* Should reference a variable name from the variables section.
|
||||
*/
|
||||
target?: string
|
||||
|
||||
/**
|
||||
* Fields that are computed from other fields rather than generated randomly.
|
||||
* Maps field name to expression that computes it.
|
||||
* Example: { "equals": "coefficient * answer + constant" }
|
||||
*/
|
||||
derived?: Record<string, string>
|
||||
|
||||
/**
|
||||
* Preferred values for fields - pedagogically nice numbers.
|
||||
* The engine will bias generation toward these values.
|
||||
*/
|
||||
preferred?: Record<string, PreferredValues>
|
||||
}
|
||||
|
||||
/**
|
||||
* Teacher/flowchart-defined constraints that generated problems must satisfy.
|
||||
* Each constraint is an expression that must evaluate to true.
|
||||
*/
|
||||
export type GenerationConstraints = Record<string, string>
|
||||
|
||||
/**
|
||||
* Configuration for how to display the problem.
|
||||
*/
|
||||
export interface DisplayConfig {
|
||||
/**
|
||||
* Expression that evaluates to the problem display string.
|
||||
* Example: "coefficient + 'x ' + operation + ' ' + constant + ' = ' + equals"
|
||||
*/
|
||||
problem?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Flowchart Definition
|
||||
// =============================================================================
|
||||
@@ -221,6 +292,24 @@ export interface FlowchartDefinition {
|
||||
* When defined, displays an evolving problem representation as the user progresses.
|
||||
*/
|
||||
workingProblem?: WorkingProblemConfig
|
||||
|
||||
/**
|
||||
* Optional: Configuration for constraint-guided example generation.
|
||||
* Defines how to generate pedagogically appropriate problems.
|
||||
*/
|
||||
generation?: GenerationConfig
|
||||
|
||||
/**
|
||||
* Optional: Constraints that generated problems must satisfy.
|
||||
* Each key is a constraint name, value is an expression that must be true.
|
||||
* Example: { "positiveAnswer": "answer > 0", "integerAnswer": "floor(answer) == answer" }
|
||||
*/
|
||||
constraints?: GenerationConstraints
|
||||
|
||||
/**
|
||||
* Optional: Configuration for how to display the problem.
|
||||
*/
|
||||
display?: DisplayConfig
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
48
apps/web/src/types/mathml.d.ts
vendored
Normal file
48
apps/web/src/types/mathml.d.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* MathML JSX type declarations for React
|
||||
* Browser support: 94%+ (Chrome 109+, Firefox, Safari 10+, Edge 109+)
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
// MathML root element
|
||||
math: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement>
|
||||
|
||||
// MathML token elements
|
||||
mi: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // identifier
|
||||
mn: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // number
|
||||
mo: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // operator
|
||||
ms: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // string literal
|
||||
mtext: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // text
|
||||
|
||||
// MathML layout elements
|
||||
mrow: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // group
|
||||
mfrac: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // fraction
|
||||
msqrt: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // square root
|
||||
mroot: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // nth root
|
||||
msup: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // superscript
|
||||
msub: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // subscript
|
||||
msubsup: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // sub+superscript
|
||||
munder: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // underscript
|
||||
mover: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // overscript
|
||||
munderover: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // under+overscript
|
||||
mtable: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // table
|
||||
mtr: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // table row
|
||||
mtd: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // table cell
|
||||
mfenced: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // fenced (parentheses)
|
||||
mspace: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // space
|
||||
mpadded: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // padded
|
||||
mphantom: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // phantom (invisible)
|
||||
menclose: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement> // enclose
|
||||
|
||||
// MathML semantic elements
|
||||
semantics: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement>
|
||||
annotation: React.DetailedHTMLProps<React.HTMLAttributes<MathMLElement>, MathMLElement>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
Reference in New Issue
Block a user