optigami / src /App.js
ianalin123's picture
feat(frontend): replay mode, camera angle fix, endpoint alignment
9221fb1
raw
history blame
7.77 kB
import { useState, useEffect, useCallback, useRef } from 'react';
import './App.css';
import CreaseCanvas from './components/CreaseCanvas';
import RewardPanel from './components/RewardPanel';
import StepFeed from './components/StepFeed';
import InfoBadges from './components/InfoBadges';
import TargetSelector from './components/TargetSelector';
import PlayerControls from './components/PlayerControls';
import Fold3DCanvas from './components/Fold3DCanvas';
const API_BASE = '';
// Read ?ep=<episode_id> from URL — set when navigating from training grid
const _urlParams = new URLSearchParams(window.location.search);
const REPLAY_EP_ID = _urlParams.get('ep') || null;
function App() {
const [targets, setTargets] = useState({});
const [selectedTarget, setSelectedTarget] = useState('half_fold');
const [episode, setEpisode] = useState(null);
const [currentStep, setCurrentStep] = useState(0);
const [playing, setPlaying] = useState(false);
const [apiStatus, setApiStatus] = useState('connecting');
const [episodeLoading, setEpisodeLoading] = useState(false);
const intervalRef = useRef(null);
const isReplayMode = REPLAY_EP_ID !== null;
const fetchTargets = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/targets`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setTargets(data);
setApiStatus('ok');
} catch {
setApiStatus('err');
}
}, []);
const fetchDemoEpisode = useCallback(async (targetName) => {
setEpisodeLoading(true);
setPlaying(false);
setCurrentStep(0);
try {
const res = await fetch(`${API_BASE}/episode/demo?target=${targetName}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEpisode(data);
setApiStatus('ok');
} catch {
setEpisode(null);
setApiStatus('err');
} finally {
setEpisodeLoading(false);
}
}, []);
const fetchReplayEpisode = useCallback(async (epId) => {
setEpisodeLoading(true);
setPlaying(false);
setCurrentStep(0);
try {
const res = await fetch(`${API_BASE}/episode/replay/${epId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEpisode(data);
setApiStatus('ok');
} catch {
setEpisode(null);
setApiStatus('err');
} finally {
setEpisodeLoading(false);
}
}, []);
useEffect(() => {
fetchTargets();
}, [fetchTargets]);
useEffect(() => {
if (isReplayMode) {
fetchReplayEpisode(REPLAY_EP_ID);
} else {
fetchDemoEpisode(selectedTarget);
}
}, [isReplayMode, selectedTarget, fetchDemoEpisode, fetchReplayEpisode]);
const totalSteps = episode ? episode.steps.length : 0;
// currentStep is 1-indexed for display (0 = "empty paper before any folds")
// steps array is 0-indexed: steps[0] = result of fold 1
const activeStepData = episode && currentStep > 0 ? episode.steps[currentStep - 1] : null;
useEffect(() => {
if (playing) {
intervalRef.current = setInterval(() => {
setCurrentStep(prev => {
if (prev >= totalSteps) {
setPlaying(false);
return prev;
}
return prev + 1;
});
}, 1500);
}
return () => clearInterval(intervalRef.current);
}, [playing, totalSteps]);
const handlePlay = () => {
if (currentStep >= totalSteps) setCurrentStep(0);
setPlaying(true);
};
const handlePause = () => setPlaying(false);
const handleNext = () => {
setPlaying(false);
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
};
const handlePrev = () => {
setPlaying(false);
setCurrentStep(prev => Math.max(prev - 1, 0));
};
const handleReset = () => {
setPlaying(false);
setCurrentStep(0);
};
const targetDef = targets[selectedTarget] || null;
return (
<div className="app">
<header className="app-header">
<span className="app-title">
OPTI<span className="title-accent">GAMI</span> RL
</span>
<div className="header-sep" />
{isReplayMode ? (
<>
<span className="replay-badge">REPLAY — {REPLAY_EP_ID}</span>
<button className="back-to-grid-btn" onClick={() => window.history.back()}>
← GRID
</button>
</>
) : (
<TargetSelector
targets={targets}
selected={selectedTarget}
onChange={name => setSelectedTarget(name)}
/>
)}
<div className="header-sep" />
<PlayerControls
playing={playing}
onPlay={handlePlay}
onPause={handlePause}
onNext={handleNext}
onPrev={handlePrev}
onReset={handleReset}
currentStep={currentStep}
totalSteps={totalSteps}
disabled={!episode || episodeLoading}
/>
<div className="header-right">
<div className="api-status">
<span className={`api-status-dot ${apiStatus === 'ok' ? 'ok' : apiStatus === 'err' ? 'err' : ''}`} />
<span>{apiStatus === 'ok' ? 'API OK' : apiStatus === 'err' ? 'API ERR' : 'CONNECTING'}</span>
</div>
</div>
</header>
<div className="app-body">
<div className="app-left">
<div className="canvas-row">
<div className="canvas-wrap">
<span className="canvas-label">
TASK — {targetDef ? targetDef.name.replace(/_/g, ' ').toUpperCase() : '—'}
</span>
<CreaseCanvas
paperState={null}
target={null}
label="TASK"
dim={280}
ghostOnly={true}
/>
</div>
<div className="canvas-wrap">
<span className="canvas-label">
{currentStep === 0 ? 'INITIAL STATE' : `STEP ${currentStep} / ${totalSteps}`}
</span>
<CreaseCanvas
paperState={activeStepData ? activeStepData.paper_state : null}
target={null}
label={currentStep === 0 ? 'INITIAL' : `STEP ${currentStep}`}
dim={280}
ghostOnly={false}
/>
</div>
<div className="canvas-wrap">
<div className="canvas-label-row">
<span className="canvas-label">3D FOLD PREVIEW</span>
</div>
<Fold3DCanvas
steps={episode ? episode.steps : []}
currentStep={currentStep}
dim={280}
/>
</div>
</div>
<div className="step-feed-section">
<div className="section-header">FOLD SEQUENCE</div>
{episodeLoading ? (
<div className="episode-loading">
<div className="pulse-dot" />
FETCHING EPISODE...
</div>
) : (
<StepFeed
steps={episode ? episode.steps : []}
currentStep={currentStep}
/>
)}
</div>
</div>
<div className="app-right">
<div className="section-header">METRICS</div>
<RewardPanel metrics={activeStepData ? activeStepData.metrics : null} />
<div className="section-header">EPISODE INFO</div>
<InfoBadges
metrics={activeStepData ? activeStepData.metrics : null}
paperState={activeStepData ? activeStepData.paper_state : null}
targetDef={targetDef}
/>
</div>
</div>
</div>
);
}
export default App;