From 05029e6f511c1008c80c2f35cae3089d7ddb3131 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 9 Jul 2026 13:27:13 +0200 Subject: [PATCH] feat: add routing with MainPage (full-screen 3D) and DetailsPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 / --- docs/3D_REBUILD_SPEC.md | 299 +++++++++++++++++++++++++++++++++++++ package-lock.json | 58 +++++++ package.json | 1 + src/App.tsx | 159 +++++++++----------- src/domain/demoPlaylist.ts | 107 +++++++++++++ src/pages/DetailsPage.tsx | 122 +++++++++++++++ src/pages/MainPage.tsx | 173 +++++++++++++++++++++ src/styles.css | 298 ++++++++++++++++++++++++++++++++++++ 8 files changed, 1130 insertions(+), 87 deletions(-) create mode 100644 docs/3D_REBUILD_SPEC.md create mode 100644 src/domain/demoPlaylist.ts create mode 100644 src/pages/DetailsPage.tsx create mode 100644 src/pages/MainPage.tsx diff --git a/docs/3D_REBUILD_SPEC.md b/docs/3D_REBUILD_SPEC.md new file mode 100644 index 0000000..1a56862 --- /dev/null +++ b/docs/3D_REBUILD_SPEC.md @@ -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 часов** diff --git a/package-lock.json b/package-lock.json index d3d8f51..41ed33c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4f2d952..017178a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/App.tsx b/src/App.tsx index 3ea3ef5..31df19f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('normal_flow'); const [demoStepIndex, setDemoStepIndex] = useState(0); - const engineeringRef = useRef(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(() => createSimulation(activeScenario)); - - // Auto Demo Director state + const [demoDirector, setDemoDirector] = useState(() => createDemoDirectorState(activeScenario), ); - - // Use refs to avoid triggering re-renders on every frame + const rafIdRef = useRef(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 ( -
-
scrollToId('demo')} - onOpenScenarios={handleShowScenarios} - onOpenEngineering={handleOpenEngineering} + + + } /> - -
- - - - - - - - - - - - - -
- -
-

OZON Tech Hackathon 2026 · Sorter Simulation · Product Demo

-
-
+ + } + /> + } /> + + ); +} + +export default function App() { + return ( + + + ); } diff --git a/src/domain/demoPlaylist.ts b/src/domain/demoPlaylist.ts new file mode 100644 index 0000000..cfefcff --- /dev/null +++ b/src/domain/demoPlaylist.ts @@ -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; diff --git a/src/pages/DetailsPage.tsx b/src/pages/DetailsPage.tsx new file mode 100644 index 0000000..68739c7 --- /dev/null +++ b/src/pages/DetailsPage.tsx @@ -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(null); + + const currentDemoStep = DEMO_STEPS[demoStepIndex]; + + const handleOpenEngineering = () => { + engineeringRef.current?.open(); + }; + + const handleShowScenarios = () => { + scrollToId('scenarios'); + }; + + const handleShowCPriority = () => { + onScenarioChange('c_priority'); + }; + + return ( +
+
scrollToId('demo')} + onOpenScenarios={handleShowScenarios} + onOpenEngineering={handleOpenEngineering} + /> + + {/* Back to main demo link */} +
+ ← Back to Full-Screen Demo +
+ +
+ + + + + + + + + + + + + +
+ +
+

OZON Tech Hackathon 2026 · Sorter Simulation · Product Demo

+
+
+ ); +} diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx new file mode 100644 index 0000000..c698465 --- /dev/null +++ b/src/pages/MainPage.tsx @@ -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 ( +
+ {/* Full-screen 3D Demo */} +
+ {show3D ? ( + { + console.error('3D Canvas failed:', error); + setContextLost(true); + }} + > + Loading 3D...
}> + setContextLost(true)} + /> + + + ) : ( + + )} +
+ + {/* Minimal HUD - top right */} +
+
+ Item + {item?.item.name ?? 'Waiting...'} +
+
+ Status + + {simulation.systemStatus} + +
+
+ Category + + {category ?? '—'} + +
+
+ Command + {command} +
+
+ Speed + 1.0 m/s +
+
+
+ Case + {playlistIndex + 1}/{PLAYLIST_LENGTH} +
+
+ {currentCase.title} +
+
+ + {/* Playlist progress bar */} +
+ {DEMO_PLAYLIST.map((c, idx) => ( +
+ ))} +
+ + {/* Play/Pause Button - center bottom */} +
+ + + {(isRunning || isPaused) && ( + + )} +
+ + {/* Details link - bottom right */} + + Details → + + + {/* 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. */} +
+ ); +} diff --git a/src/styles.css b/src/styles.css index 807d9f6..48400f4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; + } +}