feat: implement continuous 8-case playback engine for main page

- Add continuousPlayback.ts with phase-based state machine
- Add conveyorPath.ts for physics-accurate item positioning (1m=1unit, 1m/s)
- Create SorterDigitalTwinContinuous.tsx for main page 3D scene
- Update MainPage to use ContinuousPlaybackState
- Add 16 unit tests for playback logic
- Add Playwright test script for browser QA
- Screenshots: case1_start, case1_detection, case1_routing, case2_started, case8_or_finished

Timeline per case: ~6.3s (spawn → detection → classification → routing → exit)
Total playback: ~50s for all 8 cases
This commit is contained in:
root
2026-07-09 13:41:24 +02:00
parent 05029e6f51
commit ae2f38d6cf
14 changed files with 1538 additions and 66 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import MainPage from './pages/MainPage';
import DetailsPage from './pages/DetailsPage';
@@ -15,16 +15,24 @@ import {
updateAutoDemo,
type DemoDirectorState,
} from './domain/demoDirector';
import { getPlaylistCase, nextPlaylistIndex } from './domain/demoPlaylist';
import {
createPlaybackState,
startPlayback,
pausePlayback,
resumePlayback,
stopPlayback,
updatePlayback,
type ContinuousPlaybackState,
} from './domain/continuousPlayback';
function scrollToId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function AppContent() {
// Details page state (existing simulation system)
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
const [demoStepIndex, setDemoStepIndex] = useState(0);
const [playlistIndex, setPlaylistIndex] = useState(0);
const activeScenario = useMemo(
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
@@ -37,15 +45,23 @@ function AppContent() {
createDemoDirectorState(activeScenario),
);
// Main page state (continuous playback)
const [playback, setPlayback] = useState<ContinuousPlaybackState>(() => createPlaybackState());
const rafIdRef = useRef<number | null>(null);
const lastUpdateRef = useRef(performance.now());
const playbackRafRef = useRef<number | null>(null);
const playbackLastRef = useRef(performance.now());
const UPDATE_INTERVAL = 100;
const PLAYBACK_INTERVAL = 50; // More frequent updates for smooth animation
// Sync activeScenario changes
useEffect(() => {
setSimulation(createSimulation(activeScenario));
setDemoDirector(createDemoDirectorState(activeScenario));
}, [activeScenario]);
// Details page auto demo loop
useEffect(() => {
if (!demoDirector.isAutoDemoRunning || demoDirector.paused) {
if (rafIdRef.current) {
@@ -96,8 +112,42 @@ function AppContent() {
rafIdRef.current = null;
}
};
}, [demoDirector.isAutoDemoRunning, demoDirector.paused, activeScenario]);
}, [demoDirector.isAutoDemoRunning, demoDirector.paused, activeScenario, simulation.currentItem?.classification.category]);
// Main page continuous playback loop
useEffect(() => {
if (playback.status !== 'running') {
if (playbackRafRef.current) {
cancelAnimationFrame(playbackRafRef.current);
playbackRafRef.current = null;
}
return;
}
const tick = () => {
const now = performance.now();
const deltaMs = now - playbackLastRef.current;
if (deltaMs >= PLAYBACK_INTERVAL) {
playbackLastRef.current = now;
setPlayback((prev) => updatePlayback(prev, deltaMs));
}
playbackRafRef.current = requestAnimationFrame(tick);
};
playbackLastRef.current = performance.now();
playbackRafRef.current = requestAnimationFrame(tick);
return () => {
if (playbackRafRef.current) {
cancelAnimationFrame(playbackRafRef.current);
playbackRafRef.current = null;
}
};
}, [playback.status]);
// Details page handlers
const handleStartDemo = () => {
scrollToId('demo');
setSimulation((current) => {
@@ -168,24 +218,23 @@ function AppContent() {
}
};
// Main page handlers - use playlist
const handleMainPlay = () => {
const currentCase = getPlaylistCase(playlistIndex);
setActiveScenarioId(currentCase.scenarioId);
setDemoDirector((prev) => startAutoDemo(prev));
// TODO: Auto-advance to next playlist case when scenario completes
// For now, plays the current playlist case only
};
// Main page handlers - continuous playback
const handleMainPlay = useCallback(() => {
setPlayback((prev) => {
if (prev.status === 'paused') {
return resumePlayback(prev);
}
return startPlayback(prev);
});
}, []);
const handleMainPause = () => {
setDemoDirector((prev) => pauseAutoDemo(prev));
};
const handleMainPause = useCallback(() => {
setPlayback((prev) => pausePlayback(prev));
}, []);
const handleMainStop = () => {
setDemoDirector((prev) => stopAutoDemo(prev));
setSimulation(createSimulation(activeScenario));
setPlaylistIndex(0);
};
const handleMainStop = useCallback(() => {
setPlayback(stopPlayback);
}, []);
return (
<Routes>
@@ -193,9 +242,7 @@ function AppContent() {
path="/"
element={
<MainPage
simulation={simulation}
demoDirector={demoDirector}
playlistIndex={playlistIndex}
playback={playback}
onPlay={handleMainPlay}
onPause={handleMainPause}
onStop={handleMainStop}