dixiebone13-a11y
LGI v1.2: Beam Search Visualizer - Vite+React+Tailwind, all 11 audit fixes
005f181
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>
);
}