diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 994f44f..2ffe52a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { SettingsPage } from './pages/SettingsPage'; import { SortingPage } from './pages/SortingPage'; import { DPPage } from './pages/DPPage'; import { TreesPage } from './pages/TreesPage'; +import { BattlePage } from './pages/BattlePage'; import type { CatalogResponse } from './models/types'; import { api } from './services/api'; import { AudioCtx } from './context/AudioContext'; @@ -19,7 +20,7 @@ import { useSound } from './hooks/useSound'; import { fallbackCatalog } from './data/fallbackCatalog'; -type Page = 'landing' | 'sorting' | 'searching' | 'pathfinding' | 'dp' | 'trees' | 'history' | 'settings'; +type Page = 'landing' | 'sorting' | 'searching' | 'pathfinding' | 'dp' | 'trees' | 'history' | 'settings' | 'battle'; const getPageFromHash = (): Page => { const searchParams = new URLSearchParams(window.location.search); @@ -181,6 +182,7 @@ export default function App() { />
+ {active === 'battle' && } {active === 'sorting' && } {active === 'searching' && } {active === 'pathfinding' && } diff --git a/frontend/src/components/HeadToHeadBattle.tsx b/frontend/src/components/HeadToHeadBattle.tsx new file mode 100644 index 0000000..7174972 --- /dev/null +++ b/frontend/src/components/HeadToHeadBattle.tsx @@ -0,0 +1,102 @@ +import { SortingCanvas } from './SortingCanvas'; +import type { RaceResponse, SimulationFrame } from '../models/types'; +import { useMemo } from 'react'; + +interface HeadToHeadBattleProps { + response: RaceResponse; + frameA: SimulationFrame; + frameB: SimulationFrame; + algoA: string; + algoB: string; +} + +export function HeadToHeadBattle({ response, frameA, frameB, algoA, algoB }: HeadToHeadBattleProps) { + // Calculate ops for current frame + const opsA = (frameA?.comparisons ?? 0) + (frameA?.swaps ?? 0); + const opsB = (frameB?.comparisons ?? 0) + (frameB?.swaps ?? 0); + + // Delta logic (Ops A - Ops B). Positive means A did more ops than B. + const delta = opsA - opsB; + + // To render a simple differential bar chart, we can scale it to a max value. + // We'll calculate the maximum possible delta from the entire race history to scale our bar. + const maxAbsDelta = useMemo(() => { + let max = 1; + if (!response || response.lanes.length < 2) return max; + const laneA = response.lanes[0]; + const laneB = response.lanes[1]; + const maxLen = Math.max(laneA.frames.length, laneB.frames.length); + + for (let i = 0; i < maxLen; i++) { + const fa = laneA.frames[Math.min(i, laneA.frames.length - 1)]; + const fb = laneB.frames[Math.min(i, laneB.frames.length - 1)]; + const oa = (fa?.comparisons ?? 0) + (fa?.swaps ?? 0); + const ob = (fb?.comparisons ?? 0) + (fb?.swaps ?? 0); + const d = Math.abs(oa - ob); + if (d > max) max = d; + } + return max; + }, [response]); + + const percentage = Math.min(100, (Math.abs(delta) / maxAbsDelta) * 50); // Scale 0-50% for left/right + + return ( +
+ + {/* Side-by-side Visualizers */} +
+
+

{algoA}

+
+ +
+
+ Ops: {opsA} +
+
+ +
+

{algoB}

+
+ +
+
+ Ops: {opsB} +
+
+
+ + {/* Differential Graph Panel */} +
+

Live Operation Lead/Lag Delta

+

+ (Algorithm A Ops - Algorithm B Ops) +

+ +
+ {/* Center line */} +
+ + {/* The moving bar */} + {delta !== 0 && ( +
0 ? 'var(--rose-500)' : 'var(--emerald-500)', + left: delta > 0 ? '50%' : `calc(50% - ${percentage}%)`, + width: `${percentage}%`, + transition: 'all 0.1s linear', + }}>
+ )} +
+
+ {algoA} Advantage + Delta: {Math.abs(delta)} ops {delta > 0 ? `(${algoB} leading)` : (delta < 0 ? `(${algoA} leading)` : '(Tied)')} + 0 ? 'var(--rose-400)' : 'inherit' }}>{algoB} Advantage +
+
+ +
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0e432e3..3b884b9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,10 +1,11 @@ import { BarChart3, Binary, GitBranch, History, Settings, ChevronLeft, ChevronRight, Zap, LayoutGrid, X, Sun, Moon, Layers, FolderTree } from 'lucide-react'; import { useAudio } from '../context/AudioContext'; -type Page = 'landing' | 'sorting' | 'searching' | 'pathfinding' | 'dp' | 'trees' | 'history' | 'settings'; +type Page = 'landing' | 'sorting' | 'searching' | 'pathfinding' | 'dp' | 'trees' | 'history' | 'settings' | 'battle'; const items = [ { id: 'landing', label: 'Overview', icon: LayoutGrid }, + { id: 'battle', label: '1v1 Battle', icon: Zap }, { id: 'sorting', label: 'Sorting Arena', icon: BarChart3 }, { id: 'searching', label: 'Search Arena', icon: Binary }, { id: 'pathfinding', label: 'Pathfinding Arena', icon: GitBranch }, diff --git a/frontend/src/pages/BattlePage.tsx b/frontend/src/pages/BattlePage.tsx new file mode 100644 index 0000000..c88d9a6 --- /dev/null +++ b/frontend/src/pages/BattlePage.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import type { CatalogResponse, RaceResponse, SimulationFrame } from '../models/types'; +import { api } from '../services/api'; +import { useAudio } from '../context/AudioContext'; +import { Controls } from '../components/Controls'; +import { SelectField } from '../components/SelectField'; +import { Share2 } from 'lucide-react'; +import { usePlayback } from '../hooks/usePlayback'; +import { HeadToHeadBattle } from '../components/HeadToHeadBattle'; + +export function BattlePage({ catalog }: { catalog: CatalogResponse }) { + const { play } = useAudio(); + const [algorithms, setAlgorithms] = useState<[string, string]>(['Quick Sort', 'Bubble Sort']); + const [size, setSize] = useState(50); + const [datasetType, setDatasetType] = useState('Random'); + const [loading, setLoading] = useState(false); + const [activeResponse, setActiveResponse] = useState(null); + const [speed, setSpeed] = useState(1); + const [toastMessage, setToastMessage] = useState(null); + + const playback = usePlayback(activeResponse, speed); + const hasStartedPlaybackRef = useRef(false); + const winnerAnnouncedRef = useRef(false); + const requestIdRef = useRef(0); + + const fetchSimulation = useCallback( + async ( + newDataset: boolean, + autoplay = false, + customParams?: { algos?: [string, string]; dType?: string; sz?: number } + ) => { + const requestId = ++requestIdRef.current; + setLoading(true); + winnerAnnouncedRef.current = false; + if (autoplay) { + hasStartedPlaybackRef.current = true; + } else { + hasStartedPlaybackRef.current = false; + } + + const useAlgos = customParams?.algos ?? algorithms; + const useType = customParams?.dType ?? datasetType; + const useSize = customParams?.sz ?? size; + + try { + const data = await api.sorting({ + algorithms: useAlgos, + datasetType: useType, + size: useSize, + newDataset, + }); + + if (requestId !== requestIdRef.current) return; + setActiveResponse(data); + const maxFrames = Math.max(...data.lanes.map((l) => l.frames.length), 0); + playback.reset(); + + if (autoplay) { + playback.setPlaying(true); + } + } catch (error: any) { + if (requestId === requestIdRef.current) { + console.error("Battle simulation error:", error); + } + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, + [algorithms, datasetType, size, playback] + ); + + useEffect(() => { + if (catalog.sortingAlgorithms.length >= 2) { + if (!algorithms[0] || !algorithms[1]) { + setAlgorithms([catalog.sortingAlgorithms[0], catalog.sortingAlgorithms[1]]); + } + fetchSimulation(true, false, { + algos: algorithms[0] && algorithms[1] ? algorithms : [catalog.sortingAlgorithms[0], catalog.sortingAlgorithms[1]] as [string, string] + }); + } + }, [catalog.sortingAlgorithms]); + + async function startRace() { + play('start'); + if (!activeResponse || (playback.frameIndex >= playback.maxFrames - 1 && playback.maxFrames > 0)) { + await fetchSimulation(true, true); + } else { + playback.setPlaying(true); + hasStartedPlaybackRef.current = true; + } + } + + async function handleReset() { + await fetchSimulation(true, false); + } + + function handleAlgorithmChange(index: 0 | 1, nextAlgo: string) { + const nextAlgos = [...algorithms] as [string, string]; + nextAlgos[index] = nextAlgo; + setAlgorithms(nextAlgos); + fetchSimulation(false, false, { algos: nextAlgos }); + } + + const activeFrames = useMemo( + () => + activeResponse?.lanes.map((lane) => { + if (!lane.frames || lane.frames.length === 0) return undefined; + const safeIdx = Math.max(0, Math.min(playback.frameIndex, lane.frames.length - 1)); + return lane.frames[safeIdx]; + }), + [activeResponse, playback.frameIndex] + ); + + const isCompleted = !!(activeResponse && playback.frameIndex === playback.maxFrames - 1 && playback.maxFrames > 0); + const winnerLane = activeResponse?.lanes.find((l) => l.name === activeResponse.winner); + + let speedRatioStr = ''; + if (isCompleted && activeResponse && activeResponse.lanes.length === 2 && winnerLane) { + const loserLane = activeResponse.lanes.find(l => l.name !== winnerLane.name); + if (loserLane && winnerLane.stats.timeMs > 0) { + const ratio = loserLane.stats.timeMs / winnerLane.stats.timeMs; + speedRatioStr = `${ratio.toFixed(1)}x faster`; + } + } + + useEffect(() => { + if (isCompleted && activeResponse && hasStartedPlaybackRef.current && !winnerAnnouncedRef.current) { + winnerAnnouncedRef.current = true; + hasStartedPlaybackRef.current = false; + + if (activeResponse.winner) { + setTimeout(() => play('winner'), 120); + } else { + setTimeout(() => play('raceComplete'), 120); + } + } + }, [isCompleted, activeResponse, play]); + + return ( +
+
+
+

Head-to-Head 1v1 Battle

+

Direct showdown with differential live graph

+
+
+ + {isCompleted && activeResponse?.winner && ( +
+
🏆
+
+

{activeResponse.winner} Wins!

+

+ Completed sorting in {winnerLane?.stats.timeMs ?? 0} ms. + {speedRatioStr && {speedRatioStr}} +

+
+
+ )} + +
+ handleAlgorithmChange(0, next)} + /> + handleAlgorithmChange(1, next)} + /> + +
+ + playback.setPlaying(!playback.playing)} + onReset={handleReset} + onStepForward={playback.stepForward} + onStepBackward={playback.stepBackward} + frameIndex={playback.frameIndex} + maxFrames={playback.maxFrames} + onSeek={playback.seek} + speed={speed} + onSpeedChange={setSpeed} + /> + + {activeResponse && activeFrames && activeFrames.length === 2 && ( + + )} +
+ ); +} diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 881f52c..b8157e5 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -24,7 +24,7 @@ const HeroMiniCanvas = lazy(() => import('../components/HeroMiniCanvas').then(m const AlgorithmMatrix = lazy(() => import('../components/AlgorithmMatrix').then(m => ({ default: m.AlgorithmMatrix }))); interface Props { - onNavigate: (page: 'sorting' | 'searching' | 'pathfinding' | 'dp' | 'trees' | 'history' | 'settings') => void; + onNavigate: (page: 'sorting' | 'searching' | 'pathfinding' | 'dp' | 'trees' | 'history' | 'settings' | 'battle') => void; darkMode?: boolean; setDarkMode?: (val: boolean) => void; } @@ -282,6 +282,23 @@ export function LandingPage({ onNavigate, darkMode, setDarkMode }: Props) {
+
onNavigate('battle')}> +
+
+ +
+ 1v1 BATTLE ARENA +
+

Head-to-Head Algorithm Showdown

+

+ Directly compare exactly two sorting algorithms side-by-side with a real-time differential graph highlighting the exact operation lead and lag deltas. +

+
+ Launch Battle Arena + +
+
+ {/* Row 2: Card 3 (Pathfinding Arena) + Card 4 (Web Audio) */}
onNavigate('pathfinding')}>