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 (
+
+
+
+ {isCompleted && activeResponse?.winner && (
+
+
🏆
+
+
{activeResponse.winner} Wins!
+
+ Completed sorting in {winnerLane?.stats.timeMs ?? 0} ms.
+ {speedRatioStr && {speedRatioStr}}
+
+
+
+ )}
+
+
+
+ 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) {
+