Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; | |
| import { Play, Pause, RefreshCw, Settings, Activity, Zap, Layers, Calculator } from 'lucide-react'; | |
| import GridCell from './components/GridCell'; | |
| // --- Constants (fix #8: removed unused CELL_SIZE, Info, ChevronRight) --- | |
| const GRID_SIZE = 15; | |
| const CELL_COUNT = GRID_SIZE * GRID_SIZE; | |
| const DELAY_BASE = 500; | |
| const START_IDX = 0; | |
| const END_IDX = CELL_COUNT - 1; | |
| // --- Heuristic (Manhattan Distance) --- | |
| const heuristic = (ax, ay, bx, by) => Math.abs(ax - bx) + Math.abs(ay - by); | |
| // Neighbor offsets | |
| const DIRS = [{ dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }]; | |
| // --- Main Application --- | |
| export default function App() { | |
| const [grid, setGrid] = useState([]); | |
| const [beamWidth, setBeamWidth] = useState(3); | |
| const [isPlaying, setIsPlaying] = useState(false); | |
| const [searchState, setSearchState] = useState(null); | |
| const [currentStep, setCurrentStep] = useState(0); | |
| const [history, setHistory] = useState([]); | |
| const [speed, setSpeed] = useState(1); | |
| const [stats, setStats] = useState({ visited: 0, activeBeam: 0, currentBestCost: 0 }); | |
| const timerRef = useRef(null); | |
| const speedRef = useRef(speed); | |
| useEffect(() => { speedRef.current = speed; }, [speed]); | |
| // Fix #9: stable callbacks | |
| const resetSearch = useCallback(() => { | |
| clearInterval(timerRef.current); | |
| setIsPlaying(false); | |
| setSearchState(null); | |
| setCurrentStep(0); | |
| setHistory([]); | |
| setStats({ visited: 0, activeBeam: 0, currentBestCost: 0 }); | |
| }, []); | |
| const generateGrid = useCallback(() => { | |
| const newGrid = new Array(CELL_COUNT); | |
| for (let y = 0; y < GRID_SIZE; y++) { | |
| for (let x = 0; x < GRID_SIZE; x++) { | |
| const noise = Math.random(); | |
| let cost = 1; | |
| if (noise > 0.7) cost = 3; | |
| if (noise > 0.9) cost = 8; | |
| newGrid[y * GRID_SIZE + x] = { id: `${x}-${y}`, x, y, cost }; | |
| } | |
| } | |
| setGrid(newGrid); | |
| resetSearch(); | |
| }, [resetSearch]); | |
| useEffect(() => { generateGrid(); }, [generateGrid]); | |
| // --- Beam Search (fixes #1, #6, #7) --- | |
| const runBeamSearch = useCallback(() => { | |
| if (grid.length === 0) return; | |
| const startNode = grid[START_IDX]; // fix #6: direct index | |
| const endNode = grid[END_IDX]; | |
| const endX = endNode.x, endY = endNode.y; | |
| let beam = [{ | |
| node: startNode, | |
| path: [startNode.id], | |
| pathSet: new Set([startNode.id]), // fix #1: O(1) cycle check | |
| gScore: 0, | |
| fScore: heuristic(startNode.x, startNode.y, endX, endY), | |
| }]; | |
| const steps = []; | |
| const visitedGlobal = new Set([startNode.id]); | |
| let active = true; | |
| let finalPath = null; | |
| // Record initial state | |
| steps.push({ | |
| beamIds: [startNode.id], | |
| beamScores: new Map([[startNode.id, { g: 0, f: beam[0].fScore }]]), | |
| visited: new Set(visitedGlobal), | |
| prunedSet: new Set(), | |
| bestPath: null, | |
| bestPathSet: null, | |
| complete: false, | |
| }); | |
| while (active && beam.length > 0) { | |
| const candidates = []; | |
| for (const item of beam) { | |
| if (item.node.id === endNode.id) { | |
| finalPath = item.path; | |
| active = false; | |
| break; | |
| } | |
| for (const { dx, dy } of DIRS) { | |
| const nx = item.node.x + dx; | |
| const ny = item.node.y + dy; | |
| if (nx < 0 || nx >= GRID_SIZE || ny < 0 || ny >= GRID_SIZE) continue; | |
| const neighbor = grid[ny * GRID_SIZE + nx]; | |
| if (item.pathSet.has(neighbor.id)) continue; // fix #1: O(1) lookup | |
| const newG = item.gScore + neighbor.cost; | |
| const newF = newG + heuristic(nx, ny, endX, endY); | |
| const newPathSet = new Set(item.pathSet); | |
| newPathSet.add(neighbor.id); | |
| candidates.push({ | |
| node: neighbor, | |
| path: [...item.path, neighbor.id], | |
| pathSet: newPathSet, | |
| gScore: newG, | |
| fScore: newF, | |
| }); | |
| } | |
| } | |
| if (!active) break; | |
| if (candidates.length === 0) { active = false; break; } | |
| candidates.sort((a, b) => a.fScore - b.fScore); | |
| const nextBeam = candidates.slice(0, beamWidth); | |
| const pruned = candidates.slice(beamWidth); | |
| const beamIds = []; | |
| const beamScores = new Map(); | |
| for (const b of nextBeam) { | |
| visitedGlobal.add(b.node.id); | |
| beamIds.push(b.node.id); | |
| beamScores.set(b.node.id, { g: b.gScore, f: b.fScore }); | |
| } | |
| const prunedSet = new Set(); | |
| for (const p of pruned) prunedSet.add(p.node.id); | |
| const bestPath = nextBeam[0]?.path || []; | |
| steps.push({ | |
| beamIds, | |
| beamScores, | |
| visited: new Set(visitedGlobal), | |
| prunedSet, | |
| bestPath, | |
| bestPathSet: new Set(bestPath), // fix #3: pre-computed Set | |
| complete: false, | |
| }); | |
| beam = nextBeam; | |
| } | |
| if (finalPath) { | |
| steps.push({ | |
| beamIds: [], | |
| beamScores: new Map(), | |
| visited: visitedGlobal, | |
| prunedSet: new Set(), | |
| bestPath: finalPath, | |
| bestPathSet: new Set(finalPath), | |
| complete: true, | |
| }); | |
| } | |
| setHistory(steps); | |
| setSearchState('running'); | |
| setIsPlaying(true); | |
| setCurrentStep(0); | |
| }, [grid, beamWidth]); | |
| // --- Animation Loop (fix #5: no nested state updates) --- | |
| useEffect(() => { | |
| if (!isPlaying || searchState !== 'running') { | |
| clearInterval(timerRef.current); | |
| return; | |
| } | |
| timerRef.current = setInterval(() => { | |
| setCurrentStep(prev => { | |
| const next = prev + 1; | |
| if (next >= history.length) { | |
| clearInterval(timerRef.current); | |
| setIsPlaying(false); | |
| // fix #5: schedule state update outside updater | |
| setTimeout(() => { | |
| setSearchState(history[history.length - 1]?.complete ? 'finished' : 'failed'); | |
| }, 0); | |
| return prev; | |
| } | |
| return next; | |
| }); | |
| }, DELAY_BASE / speedRef.current); | |
| return () => clearInterval(timerRef.current); | |
| }, [isPlaying, searchState, history]); | |
| // Update stats from current step | |
| useEffect(() => { | |
| const stepData = history[currentStep]; | |
| if (stepData) { | |
| setStats({ | |
| visited: stepData.visited.size, | |
| activeBeam: stepData.beamIds.length, | |
| currentBestCost: stepData.beamScores.values().next().value?.g || 0, | |
| }); | |
| } | |
| }, [currentStep, history]); | |
| // --- Pre-compute per-step lookup maps for O(1) cell classification (fix #2, #3) --- | |
| const stepData = history[currentStep] || null; | |
| const beamScoreMap = stepData?.beamScores || null; | |
| const bestPathSet = stepData?.bestPathSet || null; | |
| // Compute cell props once per step, memoized | |
| const cellProps = useMemo(() => { | |
| if (!grid.length) return []; | |
| return grid.map((node) => { | |
| const isStart = node.x === 0 && node.y === 0; | |
| const isEnd = node.x === GRID_SIZE - 1 && node.y === GRID_SIZE - 1; | |
| const isObstacle = node.cost > 5; | |
| const isHighCost = node.cost > 1 && node.cost <= 5; | |
| // Determine visual class | |
| let cellClass = 'bg-slate-900 border-slate-800'; | |
| if (isStart) { | |
| cellClass = 'bg-emerald-500 border-emerald-600 shadow-emerald-500/50'; | |
| } else if (isEnd) { | |
| cellClass = 'bg-rose-500 border-rose-600 shadow-rose-500/50'; | |
| } else if (node.cost > 1) { | |
| cellClass = 'bg-slate-800 border-slate-700'; | |
| } | |
| let tooltipContent = null; | |
| if (stepData) { | |
| const beamScore = beamScoreMap?.get(node.id); | |
| const isOnBestPath = bestPathSet?.has(node.id); | |
| if (isOnBestPath && !isStart && !isEnd) { | |
| cellClass = 'bg-amber-500/20 border-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.3)] animate-pulse'; | |
| } | |
| if (beamScore) { | |
| cellClass = 'bg-blue-500 border-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.6)] z-10 scale-110 transition-transform cursor-help'; | |
| const h = beamScore.f - beamScore.g; | |
| tooltipContent = ( | |
| <> | |
| <div className="font-bold text-blue-300 border-b border-slate-600 pb-1 mb-1"> | |
| Node {node.x},{node.y} | |
| </div> | |
| <div className="flex justify-between w-full"><span className="text-slate-400">Total (f):</span> <span className="font-mono text-white">{beamScore.f.toFixed(1)}</span></div> | |
| <div className="flex justify-between w-full text-[10px]"><span className="text-slate-500">Cost (g):</span> <span className="font-mono text-slate-300">{beamScore.g.toFixed(1)}</span></div> | |
| <div className="flex justify-between w-full text-[10px]"><span className="text-slate-500">Dist (h):</span> <span className="font-mono text-slate-300">{h.toFixed(1)}</span></div> | |
| </> | |
| ); | |
| } else if (stepData.prunedSet?.has(node.id) && !isStart && !isEnd) { | |
| cellClass = 'bg-red-900/30 border-red-900/50'; | |
| } else if (stepData.visited.has(node.id) && !isStart && !isEnd && !isOnBestPath) { | |
| cellClass = 'bg-blue-900/20 border-blue-800/30'; | |
| } | |
| } | |
| return { cellClass, isObstacle, isHighCost, isStart, isEnd, tooltipContent }; | |
| }); | |
| }, [grid, stepData, beamScoreMap, bestPathSet]); | |
| // Slider handlers (fix #13) | |
| const onBeamWidth = useCallback((e) => { | |
| setBeamWidth(parseInt(e.target.value)); | |
| resetSearch(); | |
| }, [resetSearch]); | |
| const onSpeed = useCallback((e) => { | |
| setSpeed(parseInt(e.target.value)); | |
| }, []); | |
| const onScrub = useCallback((e) => { | |
| setIsPlaying(false); | |
| setCurrentStep(Number(e.target.value)); | |
| }, []); | |
| const togglePlay = useCallback(() => { | |
| setIsPlaying(p => !p); | |
| }, []); | |
| return ( | |
| <div className="min-h-screen bg-slate-950 text-slate-200 font-sans selection:bg-blue-500/30"> | |
| {/* Header */} | |
| <header className="border-b border-slate-800 bg-slate-950/50 backdrop-blur-md sticky top-0 z-50"> | |
| <div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-blue-600 rounded-lg shadow-lg shadow-blue-900/20"> | |
| <Layers size={20} className="text-white" /> | |
| </div> | |
| <div> | |
| <h1 className="font-bold text-lg tracking-tight text-white"> | |
| LGI <span className="text-slate-500 font-normal">| Lattice Grid Interface</span> | |
| </h1> | |
| <p className="text-xs text-slate-500 font-mono">Beam Search Visualizer v1.2</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-6"> | |
| <div className="hidden md:flex gap-6 text-xs font-mono text-slate-400"> | |
| <div className="flex flex-col items-end"> | |
| <span className="text-slate-600 uppercase tracking-wider">Depth</span> | |
| <span className="text-white font-bold text-lg">{currentStep}</span> | |
| </div> | |
| <div className="flex flex-col items-end"> | |
| <span className="text-slate-600 uppercase tracking-wider">Active Nodes</span> | |
| <span className="text-blue-400 font-bold text-lg">{stats.activeBeam || 0}</span> | |
| </div> | |
| </div> | |
| {/* fix #10: removed dead <a href="#"> */} | |
| <button | |
| className="p-2 hover:bg-slate-800 rounded-full transition-colors text-slate-500 hover:text-white" | |
| aria-label="Settings" | |
| onClick={() => {}} | |
| > | |
| <Settings size={20} /> | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="max-w-6xl mx-auto p-4 lg:p-8 grid grid-cols-1 lg:grid-cols-12 gap-8"> | |
| {/* Left Control Panel */} | |
| <div className="lg:col-span-3 space-y-6"> | |
| <div className="bg-slate-900/50 border border-slate-800 rounded-xl p-5 space-y-6"> | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label htmlFor="beam-width" className="text-sm font-medium text-slate-300 flex items-center gap-2"> | |
| <Activity size={14} /> Beam Width (k) | |
| </label> | |
| <span className="text-xs font-mono bg-slate-800 px-2 py-1 rounded text-blue-400">{beamWidth}</span> | |
| </div> | |
| <input | |
| id="beam-width" | |
| type="range" min="1" max="10" | |
| value={beamWidth} | |
| onChange={onBeamWidth} | |
| disabled={isPlaying} | |
| className="w-full h-2 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-blue-500" | |
| /> | |
| <p className="text-xs text-slate-500 mt-2 leading-relaxed"> | |
| Determines how many paths are kept in memory at each step. Higher width = better accuracy but slower. | |
| </p> | |
| </div> | |
| <div> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label htmlFor="speed" className="text-sm font-medium text-slate-300 flex items-center gap-2"> | |
| <Zap size={14} /> Simulation Speed | |
| </label> | |
| <span className="text-xs font-mono bg-slate-800 px-2 py-1 rounded text-amber-400">{speed}x</span> | |
| </div> | |
| <input | |
| id="speed" | |
| type="range" min="1" max="10" | |
| value={speed} | |
| onChange={onSpeed} | |
| className="w-full h-2 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-amber-500" | |
| /> | |
| </div> | |
| <hr className="border-slate-800" /> | |
| <div className="grid grid-cols-2 gap-2"> | |
| {!history.length || searchState === null ? ( | |
| <button | |
| onClick={runBeamSearch} | |
| className="col-span-2 flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-500 text-white py-3 rounded-lg font-medium transition-all shadow-lg shadow-blue-900/20 active:scale-95" | |
| > | |
| <Play size={18} fill="currentColor" /> Start Search | |
| </button> | |
| ) : ( | |
| <> | |
| <button | |
| onClick={togglePlay} | |
| className={`flex items-center justify-center gap-2 py-3 rounded-lg font-medium transition-all ${ | |
| isPlaying | |
| ? 'bg-amber-600/20 text-amber-500 border border-amber-600/50' | |
| : 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20' | |
| }`} | |
| > | |
| {isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />} | |
| {isPlaying ? 'Pause' : 'Resume'} | |
| </button> | |
| <button | |
| onClick={resetSearch} | |
| className="flex items-center justify-center gap-2 bg-slate-800 hover:bg-slate-700 text-slate-300 py-3 rounded-lg font-medium transition-all" | |
| > | |
| Reset | |
| </button> | |
| </> | |
| )} | |
| <button | |
| onClick={generateGrid} | |
| className="col-span-2 flex items-center justify-center gap-2 bg-transparent border border-slate-700 hover:border-slate-600 text-slate-400 hover:text-slate-300 py-2 rounded-lg text-sm transition-all mt-2" | |
| > | |
| <RefreshCw size={14} /> New Lattice | |
| </button> | |
| </div> | |
| </div> | |
| {/* Legend */} | |
| <div className="bg-slate-900/50 border border-slate-800 rounded-xl p-5"> | |
| <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">Node Legend</h3> | |
| <div className="space-y-3 text-sm"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-4 h-4 rounded bg-emerald-500 shadow shadow-emerald-500/50" /> | |
| <span className="text-slate-300">Start Node</span> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-4 h-4 rounded bg-rose-500 shadow shadow-rose-500/50" /> | |
| <span className="text-slate-300">Target Node</span> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-4 h-4 rounded bg-blue-500 shadow shadow-blue-500/50" /> | |
| <span className="text-slate-300">Active Beam</span> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-4 h-4 rounded bg-amber-500/20 border border-amber-500" /> | |
| <span className="text-slate-300">Current Best Path</span> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex items-center justify-center w-4 h-4"> | |
| <Calculator size={14} className="text-slate-400" /> | |
| </div> | |
| <span className="text-slate-400 italic">Hover blue nodes to see score</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right Grid Visualization */} | |
| <div className="lg:col-span-9"> | |
| <div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 lg:p-8 flex items-center justify-center relative overflow-hidden min-h-[500px]"> | |
| {/* Grid Background */} | |
| <div | |
| className="absolute inset-0 opacity-10 pointer-events-none" | |
| style={{ | |
| backgroundImage: 'radial-gradient(circle at 1px 1px, #475569 1px, transparent 0)', | |
| backgroundSize: '40px 40px', | |
| }} | |
| /> | |
| {/* Grid */} | |
| <div | |
| className="grid gap-1 relative z-10 transition-all duration-300" | |
| style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, minmax(0, 1fr))`, width: 'fit-content' }} | |
| role="grid" | |
| aria-label="Beam search lattice grid" | |
| > | |
| {cellProps.map((props, i) => ( | |
| <GridCell | |
| key={grid[i]?.id || i} | |
| cellClass={props.cellClass} | |
| isObstacle={props.isObstacle} | |
| isHighCost={props.isHighCost} | |
| isStart={props.isStart} | |
| isEnd={props.isEnd} | |
| tooltipContent={props.tooltipContent} | |
| /> | |
| ))} | |
| </div> | |
| {/* Result Overlays */} | |
| {searchState === 'finished' && ( | |
| <div className="absolute z-20 bottom-8 left-1/2 -translate-x-1/2 bg-slate-900/90 border border-emerald-500/30 text-emerald-400 px-6 py-3 rounded-full backdrop-blur-md shadow-2xl flex items-center gap-3 pointer-events-none" role="alert"> | |
| <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" /> | |
| Target Reached! Optimal path found within beam constraints. | |
| </div> | |
| )} | |
| {searchState === 'failed' && ( | |
| <div className="absolute z-20 bottom-8 left-1/2 -translate-x-1/2 bg-slate-900/90 border border-rose-500/30 text-rose-400 px-6 py-3 rounded-full backdrop-blur-md shadow-2xl flex items-center gap-3 pointer-events-none" role="alert"> | |
| <div className="w-2 h-2 bg-rose-500 rounded-full animate-pulse" /> | |
| Search Exhausted. Beam width too narrow to find target. | |
| </div> | |
| )} | |
| </div> | |
| {/* Timeline Scrubber */} | |
| {history.length > 0 && ( | |
| <div className="mt-4 flex items-center gap-4 bg-slate-900/50 p-3 rounded-xl border border-slate-800" aria-label="Search timeline"> | |
| <span className="text-xs font-mono text-slate-500 w-16 text-right">START</span> | |
| <div className="flex-1 relative h-6 flex items-center"> | |
| <input | |
| type="range" min="0" max={history.length - 1} | |
| value={currentStep} | |
| onChange={onScrub} | |
| className="w-full absolute z-20 opacity-0 cursor-pointer h-full" | |
| aria-label="Step through search" | |
| /> | |
| <div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden relative z-10 pointer-events-none"> | |
| <div | |
| className="h-full bg-gradient-to-r from-blue-600 to-emerald-500 transition-all duration-75 ease-linear" | |
| style={{ width: `${history.length > 1 ? (currentStep / (history.length - 1)) * 100 : 0}%` }} | |
| /> | |
| </div> | |
| <div | |
| className="absolute h-4 w-4 bg-white rounded-full shadow-lg z-10 pointer-events-none transition-all duration-75 ease-linear" | |
| style={{ left: `calc(${history.length > 1 ? (currentStep / (history.length - 1)) * 100 : 0}% - 8px)` }} | |
| /> | |
| </div> | |
| <span className="text-xs font-mono text-slate-500 w-16">GOAL</span> | |
| </div> | |
| )} | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| } | |