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:
299
docs/3D_REBUILD_SPEC.md
Normal file
299
docs/3D_REBUILD_SPEC.md
Normal 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
58
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
159
src/App.tsx
159
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<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
107
src/domain/demoPlaylist.ts
Normal 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
122
src/pages/DetailsPage.tsx
Normal 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
173
src/pages/MainPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
src/styles.css
298
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user