Spaces:
Running
Running
| 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={episode?.target_crease ?? 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={episode?.target_crease ?? 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; | |