feat: add routing with MainPage (full-screen 3D) and DetailsPage

- Add react-router-dom for routing
- Create MainPage with full-screen 3D demo viewport
- Create DetailsPage with all existing documentation blocks
- Create demoPlaylist.ts with 8 showcase cases
- Add styles for main page HUD, controls, progress bar
- Routes: / → MainPage, /details → DetailsPage, * → redirect to /
This commit is contained in:
root
2026-07-09 13:27:13 +02:00
parent 600699f155
commit 05029e6f51
8 changed files with 1130 additions and 87 deletions

299
docs/3D_REBUILD_SPEC.md Normal file
View File

@@ -0,0 +1,299 @@
# 3D REBUILD SPEC — Главная страница как Full-Screen Demo
**Дата:** 2026-07-06
**Цель:** Очистить главную, оставить только Play + непрерывное 3D demo. Детали в /details.
---
## 1. Target UX
**Главная страница (/):**
- Full-screen 3D demo viewport
- Одна кнопка Play (крупная, центр/низ экрана)
- 8 сценариев проигрываются автоматически подряд без ручного вмешательства
- Минимальный overlay: текущий сценарий, категория, прогресс
- Ссылка "Details" → /details
**Цель восприятия:**
- Пользователь за 5 секунд понимает: это 3D demo сортировки
- За 60 секунд видит все 8 сценариев без клика
- Жюри сразу видит физическую маршрутизацию B/C/D
---
## 2. Main Page Scope
**Что остаётся на главной:**
| Компонент | Назначение |
|-----------|------------|
| SorterDigitalTwin | Full-screen 3D canvas |
| Play/Pause кнопка | Запуск/пауза автодемо |
| Mini HUD overlay | Сценарий, категория, step, progress bar |
| "Details" link | Переход на /details |
**Что удаляется с главной:**
| Компонент | Куда идёт |
|-----------|-----------|
| Header | Упрощённый вариант (только logo + Details link) |
| HeroSection | Удаляется полностью (контекст понятен из 3D) |
| ProductDemoSection | Интегрируется в full-screen режим |
| StorylineStepper | → /details |
| ScenarioCards | → /details |
| CPriorityExplanation | → /details |
| CriteriaCards | → /details |
| EngineeringDetails | → /details |
| Footer | Минимизируется или удаляется |
---
## 3. Details Page Scope
**Новая страница /details содержит:**
- HeroSection (адаптированная, короткая)
- ScenarioCards (все 9 сценариев)
- CPriorityExplanation
- CriteriaCards
- EngineeringDetails (полные панели)
- StorylineStepper
- Оригинальный демо-режим со step-by-step управлением
**Это "документация для жюри":**
- Таблицы критериев
- Пояснения правил классификации
- Ручной просмотр отдельных сценариев
- Timeline, PID, Event Log
---
## 4. 8-Scenario Playback Sequence
**Рекомендуемый порядок показа (без jam и emergency для чистого потока):**
| # | Scenario ID | Название | Категория | Что показывает | Длительность |
|---|-------------|----------|-----------|----------------|--------------|
| 1 | `normal_flow` | Normal flow | B/C/D mix | Базовый поток, все маршруты | ~12s |
| 2 | `oversized_item` | Oversized | C | Габариты FAIL → C | ~6s |
| 3 | `round_object` | Round object | D | Roundness K≥0.7 → D | ~8s |
| 4 | `c_priority` | C priority | C | Негабарит + круглый = C | ~5s |
| 5 | `boundary_dimensions` | Boundary | B/C | Граничные размеры | ~8s |
| 6 | `close_items` | Close items | B/B/D | Очередь, spacing warning | ~10s |
| 7 | `low_confidence` | Low confidence | B/D | CV fallback, warning | ~6s |
| 8 | `normal_flow` | Final loop | B/C/D | Повтор normal для loop | ~12s |
**Общее время:** ~67 секунд на полный цикл
**Альтернатива с fault/emergency (если жюри хочет видеть):**
- Заменить #8 на `jam` или `emergency_stop`
- Добавить визуальную индикацию FAULT
---
## 5. 3D Scene Composition
**Что уже есть и работает:**
```
├── SorterDigitalTwin.tsx (главная сцена)
│ ├── Conveyor3D.tsx (лента конвейера)
│ ├── SensorRig3D.tsx (камера, лазер, ultrasonic)
│ ├── Actuator3D.tsx (gate, pusher C, pusher D)
│ ├── SortingZones3D.tsx (зоны B/C/D, roll-cages)
│ ├── Item3D.tsx (товар с реальными STL)
│ ├── RouteArrows3D.tsx (стрелки маршрутов)
│ └── SceneLabels3D.tsx (метки A/B/C/D)
```
**Реальные STL модели (6 шт, ~1.2 MB total):**
| Файл | Размер | Категория | SKU |
|------|--------|-----------|-----|
| `bottle.stl` | 319 KB | D | SKU-007 |
| `box-300.stl` | 29 KB | B | SKU-001 |
| `box-400.stl` | 27 KB | C | SKU-004 |
| `cylinder.stl` | 108 KB | D | SKU-008 |
| `lunchbox.stl` | 566 KB | B | SKU-002 |
| `plate.stl` | 125 KB | D | SKU-006 |
**Procedural fallback (без STL):**
- SKU-003 (Detergent) → box primitive
- SKU-005 (Pouf) → cylinder primitive
- SKU-009 (Pen) → thin box primitive
- SKU-010, SKU-011 → box/cylinder primitives
---
## 6. Camera Storyboard
**Использовать существующие presets из demoDirector.ts:**
| Demo Step | Camera Preset | Что видно |
|-----------|---------------|-----------|
| intro | `overview` | Вся сцена A/B/C/D |
| spawn_item | `overview` | Товар появляется в A |
| move_to_detection | `detectionCloseup` | Движение к камере |
| detection_scan | `detectionCloseup` | Bbox, CV scan |
| dimensions_check | `gateCloseup` | Накопитель, stop-gate |
| roundness_check | `gateCloseup` | Проверка формы |
| decision | `gateCloseup` | Решение B/C/D |
| command_sent | `gateCloseup` | ROUTE_TO_* |
| actuator_move | по категории | Gate/Pusher движение |
| route_item | `routingB/C/D` | Товар уходит в зону |
| result | `resultB/C/D` | Товар в целевой зоне |
| reset_or_next | `overview` | Подготовка к следующему |
**Переходы камеры:**
- Плавная интерполяция (lerp) между presets
- Длительность перехода: 400-600ms
- OrbitControls отключены во время автодемо
---
## 7. Asset Readiness
**STL модели — готовы ✅**
- 6 моделей в `/public/models/`
- Все протестированы, загружаются
- Fallback primitives для остальных
**Items — готовы ✅**
- 11 SKU в `items.ts`
- Параметры соответствуют input_info (10×10×2mm, K≥0.7)
**Scenarios — готовы ✅**
- 9 сценариев в `scenarios.ts`
- Все работают с текущей simulation
**DemoDirector — готов ✅**
- Camera presets определены
- Step sequence есть
- Auto playback реализован
**Что нужно доработать:**
- [ ] Multi-scenario playlist (сейчас один сценарий)
- [ ] Camera transitions (lerp между presets)
- [ ] Full-screen режим без UI chrome
- [ ] Loop mode для 8 сценариев
---
## 8. File Plan
### Новые файлы:
| Файл | Назначение |
|------|------------|
| `src/pages/MainPage.tsx` | Новая главная с full-screen 3D |
| `src/pages/DetailsPage.tsx` | Страница деталей |
| `src/components/DemoOverlay.tsx` | Минимальный HUD для главной |
| `src/components/PlayButton.tsx` | Крупная кнопка Play |
| `src/domain/demoPlaylist.ts` | Playlist из 8 сценариев |
### Изменения в существующих:
| Файл | Изменение |
|------|-----------|
| `src/App.tsx` | Роутинг: / → MainPage, /details → DetailsPage |
| `src/main.tsx` | Добавить React Router |
| `src/domain/demoDirector.ts` | Добавить playlist support, camera lerp |
| `src/components/ThreeD/SorterDigitalTwin.tsx` | Full-screen mode prop |
| `src/styles.css` | Стили для full-screen, overlay, play button |
### Удаление/перемещение:
| Компонент | Действие |
|-----------|----------|
| `HeroSection.tsx` | Перенести в DetailsPage |
| `ScenarioCards.tsx` | Перенести в DetailsPage |
| `CriteriaCards.tsx` | Перенести в DetailsPage |
| `CPriorityExplanation.tsx` | Перенести в DetailsPage |
| `EngineeringDetails.tsx` | Перенести в DetailsPage |
| `StorylineStepper.tsx` | Перенести в DetailsPage |
---
## 9. Risks / Open Questions
### Риски:
| Риск | Митигация |
|------|-----------|
| WebGL context lost на mobile | 2D fallback уже есть (ThreeFallback.tsx) |
| Тяжёлые STL на слабых устройствах | Fallback primitives, DPR снижение |
| Камера дёргается при быстрых переходах | Easing функция, плавный lerp |
| Жюри хочет ручной контроль | Ссылка на /details с полным контролем |
| React Router добавляет bundle | Минимальный импакт (~10KB gzipped) |
### Open Questions:
| Вопрос | Рекомендация |
|--------|--------------|
| Включать ли jam/emergency в playlist? | Нет для чистого показа, да для полноты |
| Показывать ли FPS? | Нет на главной, да в /details |
| Звук/музыка? | Нет, лишняя зависимость |
| Автозапуск при загрузке? | Нет, ждать клика Play |
| Loop бесконечно или остановка? | Loop по умолчанию, стоп по клику |
---
## 10. Step 2 Implementation Checklist
### Phase 1: Routing Setup
- [ ] Установить react-router-dom
- [ ] Создать базовый роутинг в App.tsx
- [ ] Создать пустые MainPage.tsx и DetailsPage.tsx
### Phase 2: Details Page
- [ ] Перенести компоненты из App.tsx в DetailsPage
- [ ] Сохранить всю текущую функциональность
- [ ] Проверить работоспособность /details
### Phase 3: Demo Playlist
- [ ] Создать demoPlaylist.ts с 8 сценариями
- [ ] Добавить playlist mode в demoDirector.ts
- [ ] Автопереключение сценариев по завершении
### Phase 4: Main Page
- [ ] Создать full-screen layout
- [ ] Интегрировать SorterDigitalTwin без HUD
- [ ] Добавить PlayButton.tsx
- [ ] Добавить DemoOverlay.tsx (минимальный)
### Phase 5: Camera Transitions
- [ ] Добавить lerp для camera position
- [ ] Добавить lerp для camera target
- [ ] Синхронизировать с demoDirector steps
### Phase 6: Polish
- [ ] Стили для full-screen
- [ ] Mobile responsive
- [ ] Тестирование всех 8 сценариев
- [ ] Performance check (target: 30+ FPS)
### Phase 7: QA
- [ ] Desktop 1920×1080: Play → 8 сценариев → loop
- [ ] Laptop 1440×900: То же
- [ ] Mobile 390×844: Fallback или simplified 3D
- [ ] /details: Полная функциональность
---
## Summary
**Текущее состояние:**
- 3D сцена работает
- Все компоненты готовы
- STL модели загружены
- DemoDirector с camera presets есть
**Что нужно сделать:**
- Роутинг (/ и /details)
- Playlist из 8 сценариев
- Full-screen режим главной
- Camera lerp transitions
- Минимальный overlay
**Оценка трудозатрат:**
- Phase 1-2: ~1 час
- Phase 3-4: ~2 часа
- Phase 5-6: ~1 час
- Phase 7: ~30 минут
**Total: ~4-5 часов**

58
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@vitejs/plugin-react": "latest",
"react": "latest",
"react-dom": "latest",
"react-router-dom": "^7.18.1",
"three": "^0.185.1",
"typescript": "latest",
"vite": "latest"
@@ -832,6 +833,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@@ -1444,6 +1458,44 @@
"react": "^19.2.7"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
@@ -1507,6 +1559,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -15,6 +15,7 @@
"@vitejs/plugin-react": "latest",
"react": "latest",
"react-dom": "latest",
"react-router-dom": "^7.18.1",
"three": "^0.185.1",
"typescript": "latest",
"vite": "latest"

View File

@@ -1,12 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Header from './components/Header';
import HeroSection from './components/HeroSection';
import ProductDemoSection from './components/ProductDemoSection';
import StorylineStepper from './components/StorylineStepper';
import ScenarioCards from './components/ScenarioCards';
import CPriorityExplanation from './components/CPriorityExplanation';
import CriteriaCards from './components/CriteriaCards';
import EngineeringDetails, { type EngineeringDetailsHandle } from './components/EngineeringDetails';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import MainPage from './pages/MainPage';
import DetailsPage from './pages/DetailsPage';
import { DEMO_STEPS } from './data/demoSteps';
import { SCENARIOS } from './data/scenarios';
import { createSimulation, stepSimulationToNextState, setRunning } from './domain/simulation';
@@ -20,43 +15,39 @@ import {
updateAutoDemo,
type DemoDirectorState,
} from './domain/demoDirector';
import { getPlaylistCase, nextPlaylistIndex } from './domain/demoPlaylist';
function scrollToId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
export default function App() {
function AppContent() {
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
const [demoStepIndex, setDemoStepIndex] = useState(0);
const engineeringRef = useRef<EngineeringDetailsHandle>(null);
const [playlistIndex, setPlaylistIndex] = useState(0);
const activeScenario = useMemo(
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
[activeScenarioId],
);
const currentDemoStep = DEMO_STEPS[demoStepIndex];
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
// Auto Demo Director state
const [demoDirector, setDemoDirector] = useState<DemoDirectorState>(() =>
createDemoDirectorState(activeScenario),
);
// Use refs to avoid triggering re-renders on every frame
const rafIdRef = useRef<number | null>(null);
const lastUpdateRef = useRef(performance.now());
const UPDATE_INTERVAL = 100; // Update UI only every 100ms instead of every frame
const UPDATE_INTERVAL = 100;
useEffect(() => {
setSimulation(createSimulation(activeScenario));
setDemoDirector(createDemoDirectorState(activeScenario));
}, [activeScenario]);
// Auto Demo loop - throttled updates to prevent render loop
useEffect(() => {
if (!demoDirector.isAutoDemoRunning || demoDirector.paused) {
// Clean up RAF on stop/pause
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
@@ -69,13 +60,11 @@ export default function App() {
const tick = () => {
const now = performance.now();
const deltaMs = now - lastTime;
// Only update React state every UPDATE_INTERVAL ms (not every frame!)
if (now - lastUpdateRef.current >= UPDATE_INTERVAL) {
lastTime = now;
lastUpdateRef.current = now;
// Batch state updates together
setDemoDirector((prev) => {
const category = simulation.currentItem?.classification.category;
return updateAutoDemo(prev, deltaMs, category);
@@ -107,7 +96,7 @@ export default function App() {
rafIdRef.current = null;
}
};
}, [demoDirector.isAutoDemoRunning, demoDirector.paused, activeScenario]); // Removed circular dependency!
}, [demoDirector.isAutoDemoRunning, demoDirector.paused, activeScenario]);
const handleStartDemo = () => {
scrollToId('demo');
@@ -148,19 +137,6 @@ export default function App() {
scrollToId('demo');
};
const handleOpenEngineering = () => {
engineeringRef.current?.open();
};
const handleShowScenarios = () => {
scrollToId('scenarios');
};
const handleShowCPriority = () => {
handleScenarioChange('c_priority');
};
// Auto Demo handlers
const handleStartAutoDemo = () => {
scrollToId('demo');
setDemoDirector((prev) => startAutoDemo(prev));
@@ -192,58 +168,67 @@ export default function App() {
}
};
// 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
};
const handleMainPause = () => {
setDemoDirector((prev) => pauseAutoDemo(prev));
};
const handleMainStop = () => {
setDemoDirector((prev) => stopAutoDemo(prev));
setSimulation(createSimulation(activeScenario));
setPlaylistIndex(0);
};
return (
<div className="product-page">
<Header
simulation={simulation}
onReset={handleReset}
onOpenDemo={() => scrollToId('demo')}
onOpenScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
<Routes>
<Route
path="/"
element={
<MainPage
simulation={simulation}
demoDirector={demoDirector}
playlistIndex={playlistIndex}
onPlay={handleMainPlay}
onPause={handleMainPause}
onStop={handleMainStop}
/>
}
/>
<main className="product-main">
<HeroSection
onStartDemo={handleStartDemo}
onShowScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
/>
<ProductDemoSection
simulation={simulation}
demoStepTitle={currentDemoStep.title}
demoDirector={demoDirector}
onStartDemo={handleStartDemo}
onNext={handleNext}
onReset={handleReset}
onOpenEngineering={handleOpenEngineering}
onStartAutoDemo={handleStartAutoDemo}
onToggleAutoDemo={handleToggleAutoDemo}
onStopAutoDemo={handleStopAutoDemo}
/>
<StorylineStepper simulation={simulation} />
<ScenarioCards
scenarios={SCENARIOS}
activeScenarioId={activeScenarioId}
onScenarioChange={handleScenarioChange}
/>
<CPriorityExplanation onShowCPriority={handleShowCPriority} />
<CriteriaCards onLinkedScenario={handleScenarioChange} />
<EngineeringDetails
ref={engineeringRef}
simulation={simulation}
onScenarioChange={handleScenarioChange}
/>
</main>
<footer className="site-footer">
<p>OZON Tech Hackathon 2026 · Sorter Simulation · Product Demo</p>
</footer>
</div>
<Route
path="/details"
element={
<DetailsPage
simulation={simulation}
demoStepIndex={demoStepIndex}
demoDirector={demoDirector}
activeScenarioId={activeScenarioId}
onStartDemo={handleStartDemo}
onNext={handleNext}
onReset={handleReset}
onScenarioChange={handleScenarioChange}
onStartAutoDemo={handleStartAutoDemo}
onToggleAutoDemo={handleToggleAutoDemo}
onStopAutoDemo={handleStopAutoDemo}
/>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AppContent />
</BrowserRouter>
);
}

107
src/domain/demoPlaylist.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Demo Playlist — 8 showcase cases for the main page auto-demo.
* Each case demonstrates a specific classification scenario.
*/
import type { Category, ScenarioId } from './types';
export interface PlaylistCase {
id: string;
title: string;
description: string;
itemId: string;
scenarioId: ScenarioId;
expectedCategory: Category;
warning?: string;
}
/**
* 8 showcase cases for continuous demo playback.
* Order is designed for storytelling: B → C → D → edge cases.
*/
export const DEMO_PLAYLIST: PlaylistCase[] = [
{
id: 'box_b',
title: 'Короб 300×200×200',
description: 'Стандартный короб — габариты проходят, круга нет → B',
itemId: 'SKU-001',
scenarioId: 'normal_flow',
expectedCategory: 'B',
},
{
id: 'lunchbox_b',
title: 'ЛанчБокс',
description: 'Компактная упаковка — проходит все проверки → B',
itemId: 'SKU-002',
scenarioId: 'boundary_dimensions',
expectedCategory: 'B',
},
{
id: 'oversized_box_c',
title: 'Негабаритный короб',
description: 'Размер 401×300×400 превышает max 450×320×320 по height → C',
itemId: 'SKU-004',
scenarioId: 'oversized_item',
expectedCategory: 'C',
},
{
id: 'small_item_c',
title: 'Ручка (слишком маленькая)',
description: 'Width 9mm < min 10mm — не проходит min dimensions → C',
itemId: 'SKU-009',
scenarioId: 'boundary_dimensions',
expectedCategory: 'C',
},
{
id: 'plate_d',
title: 'Тарелка',
description: 'Габариты OK, но K=0.95 ≥ 0.7 — круглое сечение → D',
itemId: 'SKU-006',
scenarioId: 'round_object',
expectedCategory: 'D',
},
{
id: 'bottle_d',
title: 'Бутылка',
description: 'Цилиндр K=0.92 — требует доупаковки → D',
itemId: 'SKU-007',
scenarioId: 'round_object',
expectedCategory: 'D',
},
{
id: 'c_priority',
title: 'Негабарит + круглый',
description: 'Oversized AND round — но C имеет приоритет над D → C',
itemId: 'SKU-011',
scenarioId: 'c_priority',
expectedCategory: 'C',
},
{
id: 'low_confidence',
title: 'Low confidence fallback',
description: 'CV confidence < 0.65 — warning, но rule-based решение работает',
itemId: 'SKU-003-LC',
scenarioId: 'low_confidence',
expectedCategory: 'B',
warning: 'Low confidence is a warning, not a 4th category. Rules still apply.',
},
];
/**
* Get playlist case by index (loops).
*/
export function getPlaylistCase(index: number): PlaylistCase {
return DEMO_PLAYLIST[index % DEMO_PLAYLIST.length];
}
/**
* Get next playlist index (loops).
*/
export function nextPlaylistIndex(current: number): number {
return (current + 1) % DEMO_PLAYLIST.length;
}
/**
* Total playlist length.
*/
export const PLAYLIST_LENGTH = DEMO_PLAYLIST.length;

122
src/pages/DetailsPage.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useRef } from 'react';
import { Link } from 'react-router-dom';
import Header from '../components/Header';
import HeroSection from '../components/HeroSection';
import ProductDemoSection from '../components/ProductDemoSection';
import StorylineStepper from '../components/StorylineStepper';
import ScenarioCards from '../components/ScenarioCards';
import CPriorityExplanation from '../components/CPriorityExplanation';
import CriteriaCards from '../components/CriteriaCards';
import EngineeringDetails, { type EngineeringDetailsHandle } from '../components/EngineeringDetails';
import { DEMO_STEPS } from '../data/demoSteps';
import { SCENARIOS } from '../data/scenarios';
import type { ScenarioId, SimulationState } from '../domain/types';
import type { DemoDirectorState } from '../domain/demoDirector';
function scrollToId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
interface DetailsPageProps {
simulation: SimulationState;
demoStepIndex: number;
demoDirector: DemoDirectorState;
activeScenarioId: ScenarioId;
onStartDemo: () => void;
onNext: () => void;
onReset: () => void;
onScenarioChange: (id: ScenarioId) => void;
onStartAutoDemo: () => void;
onToggleAutoDemo: () => void;
onStopAutoDemo: () => void;
}
export default function DetailsPage({
simulation,
demoStepIndex,
demoDirector,
activeScenarioId,
onStartDemo,
onNext,
onReset,
onScenarioChange,
onStartAutoDemo,
onToggleAutoDemo,
onStopAutoDemo,
}: DetailsPageProps) {
const engineeringRef = useRef<EngineeringDetailsHandle>(null);
const currentDemoStep = DEMO_STEPS[demoStepIndex];
const handleOpenEngineering = () => {
engineeringRef.current?.open();
};
const handleShowScenarios = () => {
scrollToId('scenarios');
};
const handleShowCPriority = () => {
onScenarioChange('c_priority');
};
return (
<div className="product-page details-page">
<Header
simulation={simulation}
onReset={onReset}
onOpenDemo={() => scrollToId('demo')}
onOpenScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
/>
{/* Back to main demo link */}
<div className="back-to-main">
<Link to="/" className="back-link">← Back to Full-Screen Demo</Link>
</div>
<main className="product-main">
<HeroSection
onStartDemo={onStartDemo}
onShowScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
/>
<ProductDemoSection
simulation={simulation}
demoStepTitle={currentDemoStep.title}
demoDirector={demoDirector}
onStartDemo={onStartDemo}
onNext={onNext}
onReset={onReset}
onOpenEngineering={handleOpenEngineering}
onStartAutoDemo={onStartAutoDemo}
onToggleAutoDemo={onToggleAutoDemo}
onStopAutoDemo={onStopAutoDemo}
/>
<StorylineStepper simulation={simulation} />
<ScenarioCards
scenarios={SCENARIOS}
activeScenarioId={activeScenarioId}
onScenarioChange={onScenarioChange}
/>
<CPriorityExplanation onShowCPriority={handleShowCPriority} />
<CriteriaCards onLinkedScenario={onScenarioChange} />
<EngineeringDetails
ref={engineeringRef}
simulation={simulation}
onScenarioChange={onScenarioChange}
/>
</main>
<footer className="site-footer">
<p>OZON Tech Hackathon 2026 · Sorter Simulation · Product Demo</p>
</footer>
</div>
);
}

173
src/pages/MainPage.tsx Normal file
View File

@@ -0,0 +1,173 @@
import { lazy, Suspense, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import ThreeFallback from '../components/ThreeD/ThreeFallback';
import ThreeErrorBoundary from '../components/ThreeD/ThreeErrorBoundary';
import { prefer3DByDefault, useWebGLSupport } from '../components/ThreeD/useWebGL';
import { DEMO_PLAYLIST, getPlaylistCase, PLAYLIST_LENGTH } from '../domain/demoPlaylist';
import type { SimulationState } from '../domain/types';
import type { DemoDirectorState } from '../domain/demoDirector';
const SorterDigitalTwin = lazy(() => import('../components/ThreeD/SorterDigitalTwin'));
interface MainPageProps {
simulation: SimulationState;
demoDirector: DemoDirectorState;
playlistIndex: number;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
}
export default function MainPage({
simulation,
demoDirector,
playlistIndex,
onPlay,
onPause,
onStop,
}: MainPageProps) {
const webgl = useWebGLSupport();
const [width, setWidth] = useState(() => (typeof window === 'undefined' ? 1200 : window.innerWidth));
const [contextLost, setContextLost] = useState(false);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const show3D = prefer3DByDefault(width, webgl && !contextLost);
const simplified = width < 900;
const isRunning = demoDirector.isAutoDemoRunning && !demoDirector.paused;
const isPaused = demoDirector.isAutoDemoRunning && demoDirector.paused;
const item = simulation.currentItem;
const category = item?.classification.category;
const command = simulation.machineState.startsWith('ROUTE_TO_')
? simulation.machineState
: category
? `ROUTE_TO_${category}`
: 'IDLE';
const currentCase = getPlaylistCase(playlistIndex);
const handlePlayPause = () => {
if (isRunning) {
onPause();
} else {
onPlay();
}
};
return (
<div className="main-page">
{/* Full-screen 3D Demo */}
<div className="main-demo-viewport">
{show3D ? (
<ThreeErrorBoundary
onError={(error) => {
console.error('3D Canvas failed:', error);
setContextLost(true);
}}
>
<Suspense fallback={<div className="three-loading">Loading 3D...</div>}>
<SorterDigitalTwin
simulation={simulation}
simplified={simplified}
showFps={false}
cleanView={true}
onContextLost={() => setContextLost(true)}
/>
</Suspense>
</ThreeErrorBoundary>
) : (
<ThreeFallback
simulation={simulation}
reason={!webgl || contextLost ? 'webgl' : width < 640 ? 'mobile' : 'user'}
/>
)}
</div>
{/* Minimal HUD - top right */}
<div className="main-hud">
<div className="hud-row">
<span className="hud-label">Item</span>
<span className="hud-value">{item?.item.name ?? 'Waiting...'}</span>
</div>
<div className="hud-row">
<span className="hud-label">Status</span>
<span className={`hud-value status-${simulation.systemStatus.toLowerCase()}`}>
{simulation.systemStatus}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Category</span>
<span className={`hud-value ${category ? `category-${category}` : ''}`}>
{category ?? '—'}
</span>
</div>
<div className="hud-row">
<span className="hud-label">Command</span>
<span className="hud-value">{command}</span>
</div>
<div className="hud-row">
<span className="hud-label">Speed</span>
<span className="hud-value">1.0 m/s</span>
</div>
<div className="hud-divider" />
<div className="hud-row">
<span className="hud-label">Case</span>
<span className="hud-value">{playlistIndex + 1}/{PLAYLIST_LENGTH}</span>
</div>
<div className="hud-row hud-row-small">
<span className="hud-value">{currentCase.title}</span>
</div>
</div>
{/* Playlist progress bar */}
<div className="main-progress">
{DEMO_PLAYLIST.map((c, idx) => (
<div
key={c.id}
className={`progress-dot ${idx === playlistIndex ? 'active' : ''} ${idx < playlistIndex ? 'done' : ''}`}
title={c.title}
/>
))}
</div>
{/* Play/Pause Button - center bottom */}
<div className="main-controls">
<button
type="button"
className="play-button"
onClick={handlePlayPause}
aria-label={isRunning ? 'Pause demo' : 'Play demo'}
>
{isRunning ? (
<span className="play-icon">⏸</span>
) : (
<span className="play-icon">▶</span>
)}
<span className="play-text">{isRunning ? 'Pause' : isPaused ? 'Resume' : 'Play Demo'}</span>
</button>
{(isRunning || isPaused) && (
<button type="button" className="stop-button" onClick={onStop} aria-label="Stop demo">
⏹
</button>
)}
</div>
{/* Details link - bottom right */}
<Link to="/details" className="details-link">
Details →
</Link>
{/* TODO marker for future playlist auto-advance */}
{/* TODO: Implement full 8-case auto-advance when one scenario completes.
Current behavior: plays first playlist case only.
Need to connect playlist advancement to simulation completion events. */}
</div>
);
}

View File

@@ -2037,3 +2037,301 @@ button:disabled {
transition: none;
}
}
/* =========================================================
Main Page — Full-Screen 3D Demo
========================================================= */
.main-page {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg);
}
.main-demo-viewport {
position: absolute;
inset: 0;
z-index: 1;
}
.main-demo-viewport .digital-twin-wrap {
height: 100%;
}
.main-demo-viewport .digital-twin-canvas {
height: 100% !important;
min-height: 100% !important;
border: none;
border-radius: 0;
}
.main-demo-viewport .digital-twin-hud {
display: none;
}
.main-demo-viewport .three-fallback-note {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90%;
}
/* Main page HUD - top right */
.main-hud {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
max-width: 240px;
padding: 14px 16px;
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: 16px;
background: rgba(5, 9, 16, 0.85);
backdrop-filter: blur(12px);
}
.hud-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-size: 13px;
}
.hud-row-small {
justify-content: flex-end;
}
.hud-row-small .hud-value {
font-size: 12px;
color: var(--muted);
}
.hud-label {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hud-value {
font-weight: 700;
color: var(--text);
}
.hud-value.status-running { color: var(--green); }
.hud-value.status-paused { color: var(--orange); }
.hud-value.status-fault,
.hud-value.status-emergency_stop { color: var(--red); }
.hud-divider {
height: 1px;
margin: 4px 0;
background: var(--line);
}
/* Progress dots */
.main-progress {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
gap: 10px;
padding: 10px 16px;
border-radius: 999px;
background: rgba(5, 9, 16, 0.75);
backdrop-filter: blur(8px);
}
.progress-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #334d66;
transition: background 200ms ease, transform 200ms ease;
}
.progress-dot.active {
background: var(--cyan);
transform: scale(1.3);
box-shadow: 0 0 12px var(--cyan);
}
.progress-dot.done {
background: var(--green);
}
/* Play/Pause controls */
.main-controls {
position: absolute;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
gap: 12px;
align-items: center;
}
.play-button {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 32px;
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
color: #041018;
font-size: 18px;
font-weight: 800;
cursor: pointer;
transition: transform 160ms ease, box-shadow 160ms ease;
}
.play-button:hover {
transform: scale(1.05);
box-shadow: 0 8px 32px rgba(56, 189, 248, 0.4);
}
.play-icon {
font-size: 20px;
}
.play-text {
font-size: 16px;
}
.stop-button {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: 1px solid rgba(251, 61, 78, 0.5);
border-radius: 50%;
background: rgba(251, 61, 78, 0.15);
color: #fda4af;
font-size: 18px;
cursor: pointer;
}
.stop-button:hover {
background: rgba(251, 61, 78, 0.25);
border-color: var(--red);
}
/* Details link */
.details-link {
position: absolute;
bottom: 16px;
right: 16px;
z-index: 10;
padding: 10px 16px;
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 999px;
background: rgba(5, 9, 16, 0.75);
backdrop-filter: blur(8px);
color: var(--cyan);
font-size: 14px;
font-weight: 700;
text-decoration: none;
transition: background 160ms ease, border-color 160ms ease;
}
.details-link:hover {
background: rgba(56, 189, 248, 0.1);
border-color: var(--cyan);
}
/* Details page - back link */
.back-to-main {
padding: 12px clamp(16px, 3vw, 28px);
background: rgba(56, 189, 248, 0.06);
border-bottom: 1px solid var(--line);
}
.back-link {
color: var(--cyan);
font-size: 14px;
font-weight: 700;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
/* Mobile responsive for main page */
@media (max-width: 639px) {
.main-hud {
top: 12px;
right: 12px;
left: 12px;
max-width: none;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding: 10px 12px;
}
.hud-row {
flex: 1 1 calc(50% - 8px);
min-width: 80px;
}
.hud-divider {
flex-basis: 100%;
margin: 2px 0;
}
.main-progress {
bottom: 90px;
gap: 8px;
padding: 8px 12px;
}
.progress-dot {
width: 8px;
height: 8px;
}
.main-controls {
bottom: 20px;
}
.play-button {
min-height: 48px;
padding: 0 24px;
font-size: 16px;
}
.play-icon {
font-size: 18px;
}
.play-text {
font-size: 14px;
}
.stop-button {
width: 42px;
height: 42px;
}
.details-link {
bottom: 12px;
right: 12px;
padding: 8px 12px;
font-size: 12px;
}
}