Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -181,6 +182,7 @@ export default function App() {
/>

<div className="content-shell">
{active === 'battle' && <BattlePage catalog={catalog} />}
{active === 'sorting' && <SortingPage catalog={catalog} />}
{active === 'searching' && <SearchingPage catalog={catalog} />}
{active === 'pathfinding' && <PathfindingPage catalog={catalog} />}
Expand Down
102 changes: 102 additions & 0 deletions frontend/src/components/HeadToHeadBattle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="battle-container" style={{ display: 'flex', flexDirection: 'column', gap: '24px', marginTop: '24px' }}>

{/* Side-by-side Visualizers */}
<div className="battle-canvases" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="battle-lane panel" style={{ padding: '16px' }}>
<h3 style={{ marginBottom: '12px', textAlign: 'center' }}>{algoA}</h3>
<div style={{ height: '240px', background: '#0b0b1e', borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
<SortingCanvas frame={frameA} algorithm={algoA} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
<span>Ops: {opsA}</span>
</div>
</div>

<div className="battle-lane panel" style={{ padding: '16px' }}>
<h3 style={{ marginBottom: '12px', textAlign: 'center' }}>{algoB}</h3>
<div style={{ height: '240px', background: '#0b0b1e', borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
<SortingCanvas frame={frameB} algorithm={algoB} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
<span>Ops: {opsB}</span>
</div>
</div>
</div>

{/* Differential Graph Panel */}
<div className="panel differential-panel" style={{ padding: '24px', textAlign: 'center' }}>
<h4>Live Operation Lead/Lag Delta</h4>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '16px' }}>
(Algorithm A Ops - Algorithm B Ops)
</p>

<div className="delta-bar-wrapper" style={{ position: 'relative', height: '24px', background: 'rgba(255,255,255,0.05)', borderRadius: '12px', overflow: 'hidden', marginTop: '8px' }}>
{/* Center line */}
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: '2px', background: 'rgba(255,255,255,0.2)', zIndex: 10 }}></div>

{/* The moving bar */}
{delta !== 0 && (
<div style={{
position: 'absolute',
top: 0,
bottom: 0,
background: delta > 0 ? 'var(--rose-500)' : 'var(--emerald-500)',
left: delta > 0 ? '50%' : `calc(50% - ${percentage}%)`,
width: `${percentage}%`,
transition: 'all 0.1s linear',
}}></div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
<span style={{ color: delta < 0 ? 'var(--emerald-400)' : 'inherit' }}>{algoA} Advantage</span>
<span style={{ fontWeight: 'bold' }}>Delta: {Math.abs(delta)} ops {delta > 0 ? `(${algoB} leading)` : (delta < 0 ? `(${algoA} leading)` : '(Tied)')}</span>
<span style={{ color: delta > 0 ? 'var(--rose-400)' : 'inherit' }}>{algoB} Advantage</span>
</div>
</div>

</div>
);
}
3 changes: 2 additions & 1 deletion frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
215 changes: 215 additions & 0 deletions frontend/src/pages/BattlePage.tsx
Original file line number Diff line number Diff line change
@@ -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<RaceResponse | null>(null);
const [speed, setSpeed] = useState(1);
const [toastMessage, setToastMessage] = useState<string | null>(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 (
<main className="page">
<header className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1>Head-to-Head 1v1 Battle</h1>
<p>Direct showdown with differential live graph</p>
</div>
</header>

{isCompleted && activeResponse?.winner && (
<div className="winner-banner" style={{ background: 'linear-gradient(90deg, rgba(16,185,129,0.1), rgba(6,182,212,0.1))', border: '1px solid var(--emerald-500)', marginBottom: '24px' }}>
<div className="winner-trophy">🏆</div>
<div className="winner-details">
<h3 style={{ color: 'var(--emerald-400)' }}>{activeResponse.winner} Wins!</h3>
<p>
Completed sorting in <strong>{winnerLane?.stats.timeMs ?? 0} ms</strong>.
{speedRatioStr && <span style={{ marginLeft: '8px', padding: '2px 8px', borderRadius: '12px', background: 'var(--emerald-500)', color: '#fff', fontSize: '0.85rem' }}>{speedRatioStr}</span>}
</p>
</div>
</div>
)}

<section className="panel config-panel">
<SelectField
label="Algorithm A (Lane 1)"
value={algorithms[0]}
options={catalog.sortingAlgorithms}
onChange={(next) => handleAlgorithmChange(0, next)}
/>
<SelectField
label="Algorithm B (Lane 2)"
value={algorithms[1]}
options={catalog.sortingAlgorithms}
onChange={(next) => handleAlgorithmChange(1, next)}
/>
<label className="field">
<span>Array Size</span>
<input
type="number"
min={1}
max={160}
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
fetchSimulation(true, false, { sz: Number(e.target.value) });
}}
/>
</label>
</section>

<Controls
playing={playback.playing}
disabled={loading}
onStart={startRace}
onToggle={() => 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 && (
<HeadToHeadBattle
response={activeResponse}
frameA={activeFrames[0]!}
frameB={activeFrames[1]!}
algoA={algorithms[0]}
algoB={algorithms[1]}
/>
)}
</main>
);
}
19 changes: 18 additions & 1 deletion frontend/src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -282,6 +282,23 @@ export function LandingPage({ onNavigate, darkMode, setDarkMode }: Props) {
</div>
</div>

<div className="bento-card bento-card-large" onClick={() => onNavigate('battle')}>
<div className="bento-card-header">
<div className="bento-icon-wrapper icon-purple">
<Zap size={22} />
</div>
<span className="bento-arena-tag">1v1 BATTLE ARENA</span>
</div>
<h3 className="bento-title">Head-to-Head Algorithm Showdown</h3>
<p className="bento-text">
Directly compare exactly two sorting algorithms side-by-side with a real-time differential graph highlighting the exact operation lead and lag deltas.
</p>
<div className="bento-card-action">
<span>Launch Battle Arena</span>
<ArrowRight size={16} />
</div>
</div>

{/* Row 2: Card 3 (Pathfinding Arena) + Card 4 (Web Audio) */}
<div className="bento-card bento-card-large bento-pathfinding" onClick={() => onNavigate('pathfinding')}>
<div className="bento-card-bg-glow glow-cyan-subtle" />
Expand Down
Loading