design: redesign product demo UI

This commit is contained in:
root
2026-07-04 12:14:08 +02:00
parent cb38cd0002
commit a7025ca50c
20 changed files with 2546 additions and 2207 deletions

View File

@@ -0,0 +1,22 @@
---
description: Accessibility and visual QA checklist for product demo
alwaysApply: true
---
# Accessibility and Visual QA Rules
- Контраст должен быть читаемым.
- Фокус-состояния для кнопок обязательны.
- Кнопки должны иметь понятные labels.
- Не использовать только цвет для передачи смысла.
- Текст должен быть читаемым на проекторе.
- Проверить визуально:
- 1920×1080
- 1440×900
- 390×844
- Проверить:
- нет horizontal scroll;
- hero понятен;
- CTA виден;
- demo section понятен;
- mobile layout не развален.

View File

@@ -0,0 +1,24 @@
---
description: React design system and component boundaries for sorter demo
globs: src/**/*.{tsx,ts,css}
alwaysApply: false
---
# React Design System Rules
- Не ломать domain logic.
- UI-компоненты должны быть небольшими и понятными.
- Переиспользовать данные scenarios / criteria / demoSteps.
- Не добавлять тяжёлые UI-библиотеки.
- Не добавлять 3D.
- Не добавлять backend.
- Компоненты:
- HeroSection
- ProductDemoSection
- StorylineStepper
- ScenarioCards
- CriteriaCards
- EngineeringDetails
- Старые инженерные панели можно использовать внутри EngineeringDetails.
- Стили централизовать в styles.css или небольших CSS-блоках.
- Не делать хаотичный refactor всего проекта.

View File

@@ -0,0 +1,28 @@
---
description: Responsive breakpoints and layout rules for product demo
alwaysApply: true
---
# Responsive Product Demo Rules
Breakpoints:
- desktop: >= 1200px
- laptop/tablet landscape: 900–1199px
- tablet: 640–899px
- mobile: < 640px
Требования:
- body/html/#root не должны создавать horizontal scroll.
- Проверять overflow-x.
- Все секции используют width: min(100%, ...), max-width, padding через clamp().
- На mobile:
- одна колонка;
- крупные кнопки;
- короткие тексты;
- инженерные детали свернуты;
- сцена масштабируется;
- сценарии идут карточками.
- На desktop:
- Hero + demo в понятных секциях;
- 2 колонки только там, где хватает ширины;
- без внутреннего хаотичного скролла.

View File

@@ -0,0 +1,33 @@
---
description: UI/UX Pro Max — product demo page, not debug dashboard
alwaysApply: true
---
# UI/UX Pro Max Rules
Ты обязан проектировать интерфейс как demo product page, а не debug dashboard.
Обязательные принципы:
- Пользователь должен понять смысл проекта за 10 секунд.
- Первый экран должен объяснять:
1. Что это за система.
2. Что она делает.
3. Как запустить демо.
4. Какой результат будет показан.
- Главный интерфейс должен вести пользователя по цепочке:
Detection → Classification → Command → Routing.
- Не показывать все инженерные панели сразу.
- Инженерные детали должны быть ниже или в отдельном раскрываемом блоке.
- Не должно быть горизонтального скролла.
- Mobile-first адаптив обязателен.
- Основной текст не меньше 16px.
- Кнопки не меньше 44px по высоте.
- На mobile всё в одну колонку.
- На desktop допустима сетка 2 колонки.
- Визуальная иерархия:
Hero → Demo → Result → Scenarios → Criteria → Engineering Details.
- Запрещены мелкие debug-панели на первом экране.
- Запрещены узкие внутренние scroll-панели для сценариев.
- Запрещены fixed width больше viewport.
- Использовать min-width: 0 для grid/flex children.
- SVG/сцена должны масштабироваться через width: 100%, max-width: 100%.

View File

@@ -8,12 +8,12 @@
- маршрутизация в зоны B/C/D;
- отработка аварийных ситуаций (Jam, Emergency Stop).
UI переработан под **Product Demo Landing Page**, который:
- работает без горизонтального скролла на любых экранах;
- адаптирован для Mobile, Tablet и Desktop;
- включает Storyline (Detection → Classification → Command → Routing);
- предлагает список Jury Scenarios и OZON Criteria;
- прячет сложный Engineering Dashboard в сворачиваемый аккордеон.
UI — **Product Demo Landing Page**:
- без горизонтального скролла на desktop / laptop / mobile;
- mobile-first адаптив (одна колонка < 640px);
- storyline Detection → Classification → Decision → Command → Routing;
- карточки сценариев и критериев OZON;
- Engineering Details свёрнуты по умолчанию.
## Стек
@@ -35,13 +35,46 @@ https://www.arhipovdan.ru/
http://127.0.0.1:3100/
```
## Структура Demo-страницы
## Как устроен новый UI
1. **Hero Section:** краткая суть проекта и цепочки действий.
2. **Product Demo Section:** живая сцена (`SorterScene`) и карточка текущего результата.
3. **Scenarios Cards:** карточки для проверки нестандартных товаров (негабарит, шар, затор).
4. **Criteria Cards:** чек-лист покрытия требований OZON.
5. **Engineering Details:** подробные отладочные панели (Event Log, PID, метрики).
1. **Hero** — что это за система, CTA «Запустить демо», цепочка Detection → Routing.
2. **Product Demo** — упрощённая сцена (`SorterScene variant="simple"`) + карточка результата + Start / Next / Reset.
3. **Storyline Stepper** — текущий этап цикла.
4. **Scenario Cards** — jury-кейсы карточками (кнопка «Показать»).
5. **Criteria Cards** — покрытие критериев OZON со ссылкой на сценарий.
6. **Engineering Details** — полные техпанели (state machine, sensors, PID, timeline, event log, criteria).
## Как запустить демо
1. Откройте сайт.
2. Нажмите **Запустить демо** в Hero или **Start demo** в блоке демо.
3. Нажимайте **Next step**, чтобы пройти цикл товара.
4. Выберите сценарий в карточках ниже (негабарит, круглый объект, jam и т.д.).
5. Для экспертов откройте **Инженерный режим** / **Engineering Details**.
## Где Engineering Details
Внизу страницы, секция **Engineering Details**. По умолчанию свёрнута. Кнопки «Инженерный режим» в header / hero / demo раскрывают блок и скроллят к нему. Внутри — полная сцена (`variant="full"`) и все инженерные панели.
## Как проверить mobile
1. Откройте DevTools → device toolbar.
2. Выберите `390×844` (или iPhone 12/13).
3. Проверьте:
- одна колонка;
- кнопки ≥ 44px;
- сцена масштабируется (`width: 100%`);
- нет horizontal scroll.
## Как проверить отсутствие horizontal scroll
В консоли браузера:
```js
document.documentElement.scrollWidth <= document.documentElement.clientWidth
```
Должно вернуть `true` на ширинах 1920, 1440 и 390.
## Запуск локально
@@ -116,7 +149,7 @@ State machine управляет циклом:
## Simplified PID
PID-панель показывает упрощенную имитацию control loop: target speed, actual speed, error, correction и mini graph последних тиков скорости.
PID-панель (в Engineering Details) показывает упрощенную имитацию control loop: target speed, actual speed, error, correction и mini graph последних тиков скорости.
В normal flow actual speed приближается к target. В `jam` и `emergency_stop` target становится 0, actual speed визуально падает к 0.
@@ -131,11 +164,21 @@ npm run test
## Документация
- `docs/ARCHITECTURE.md` — модули и поток данных.
- `docs/DEMO_SCRIPT.md` — 3-5 минутный сценарий защиты.
- `docs/DEMO_SCRIPT.md` — сценарий защиты.
- `docs/SCENARIOS.md` — ожидаемые результаты сценариев.
- `docs/JURY_QA.md` — короткие ответы на вопросы жюри.
- `docs/JURY_QA.md` — ответы на вопросы жюри.
- `docs/UI_UX_REDESIGN_AUDIT.md` — план редизайна UI.
- `docs/SUBMISSION_CHECKLIST.md` — checklist перед сдачей.
## Cursor rules
Локальные UI/UX rules в `.cursor/rules/`:
- `ui-ux-pro-max.mdc`
- `responsive-product-demo.mdc`
- `react-design-system.mdc`
- `accessibility-and-visual-qa.mdc`
## Ограничения MVP
- Физика движения дискретная, без динамической модели массы/трения.

View File

@@ -1,79 +1,97 @@
# Demo Script
Open https://arhipovdan.ru/ and switch Header to `Guided Demo`.
Open https://arhipovdan.ru/ (или http://127.0.0.1:3100/).
Страница — product demo: Hero → Demo → Storyline → Scenarios → Criteria → Engineering Details.
## 30-Second Pitch
"We built an engineering simulation of the OZON sorting cell. It models the full loop: detection -> classification -> command -> routing. We cover happy paths to B/C/D, fault handling, and timing. The Guided Demo view shows the live proof."
«Мы сделали инженерную симуляцию сортировочной ячейки OZON. Полный цикл: detection → classification → command → routing. Показываем happy path B/C/D, fault handling и timing. Главный экран — product demo, инженерные панели ниже.»
## 3-Minute Guided Demo
## 3-Minute Demo
1. Open **Guided Demo**.
2. Click **Start Guided Demo**.
3. Point to the **Current Proof Card** and the **Scene**.
- "Watch how detection leads to classification and routing."
4. Click **Next** to show `Oversized item to C`.
- "Dimensions fail first, priority C."
5. Click **Next** to show `Round object to D`.
- "Dimensions pass, but roundness triggers D."
6. Click **Next** through `Boundary`, `Low confidence`, `Jam`, `Emergency stop`.
- On faults: click "Apply fault scenario" to confirm.
- "The line stops safely on emergency."
1. На первом экране укажите Hero: что это за система и цепочку Detection → Classification → Command → Routing.
2. Нажмите **Запустить демо**.
3. В блоке **Главное демо** покажите упрощённую сцену и карточку результата:
- товар;
- категория B/C/D;
- причина решения;
- команда `ROUTE_TO_*`;
- целевая зона.
4. Нажимайте **Next step**, следите за **Этапами цикла**.
5. В **Сценариях** нажмите **Показать** на «Негабарит» — приоритет C.
6. Затем «Круглый объект» — зона D.
7. Затем «Застревание» или «Аварийная остановка» — FAULT / EMERGENCY_STOP, Reset.
## 5-Minute Guided Demo
## 5-Minute Demo
Use all 10 demo steps in Guided Demo View:
Пройдите сценарии по карточкам:
1. System overview.
2. Normal item to B.
3. Oversized item to C.
4. Round object to D.
5. Boundary dimensions.
6. Low confidence fallback.
7. Close items queue.
8. Jam / fault handling.
9. Emergency stop.
10. Performance and synchronization.
1. Обычный товар — B/C/D поток.
2. Негабарит — C.
3. Круглый объект — D.
4. Пограничные размеры — строгие min/max.
5. Низкая уверенность CV — fallback.
6. Очередь товаров — queue/spacing.
7. Застревание — FAULT.
8. Аварийная остановка — EMERGENCY_STOP.
For each step:
Для каждого:
1. Read the **Demo Narration Card**.
2. Use **Next** or the **Primary Action Button**.
3. Point to the highlighted values in the **Proof Card**.
4. Read the **Presenter Phrase** from the screen.
1. Нажмите **Показать**.
2. **Start demo** / **Next step**.
3. Укажите категорию, причину и маршрут на карточке результата.
4. Покажите активный шаг в Storyline Stepper.
## What to say on each step
## Engineering Details
Use the `presenterPhrase` text shown in the **Demo Narration Card**. It is specifically written to be short and clear for the jury.
Если жюри просит PID, event log, sensors или полный layout:
## What to do if demo gets stuck
1. Нажмите **Инженерный режим** (header / hero / demo).
2. Откроется секция **Engineering Details** с полными панелями.
3. Полная сцена — `SorterScene variant="full"`.
- Click **Reset Demo**.
- If still stuck, switch back to **Engineering Mode** and use manual scenario selection.
- Proceed manually.
## Что делать, если демо зависло
## Fallback If Domain Does Not Open
1. **Reset** в header или в блоке демо.
2. Выберите сценарий заново карточкой **Показать**.
3. При необходимости откройте Engineering Details и смените сценарий там.
1. SSH to server.
2. Check local frontend:
## Fallback, если домен не открывается
1. SSH на сервер.
2. Проверьте локальный frontend:
```bash
curl -I http://127.0.0.1:3100/
```
3. Check Docker project:
3. Проверьте Docker:
```bash
docker compose -p owl -f /opt/arhipovdan/app/docker-compose.server.yml ps
```
4. If screenshots/video are prepared later, show them while explaining the same demo steps.
## Mobile / no horizontal scroll
## What Each Screen Proves
Перед защитой проверьте:
- SVG scene proves physical route and actuator command.
- Classification panel proves why category B/C/D was selected.
- Timeline proves synchronization and step-by-step behavior.
- PID panel proves conveyor control-loop behavior.
- Event Log proves traceability.
- Criteria panel proves coverage of OZON evaluation points.
- 1920×1080 — hero + demo читаемы, CTA видны;
- 1440×900 — нет debug-dashboard на первом экране;
- 390×844 — одна колонка, крупные кнопки, сцена не вылезает.
В консоли:
```js
document.documentElement.scrollWidth <= document.documentElement.clientWidth
```
## What Each Block Proves
- Hero — смысл проекта за 10 секунд.
- Product Demo scene — физический маршрут и команда.
- Proof card — почему выбрана категория B/C/D.
- Storyline — текущий этап цикла.
- Scenario cards — jury test cases без узкого скролл-списка.
- Criteria cards — покрытие критериев OZON.
- Engineering Details — timeline, PID, event log, sensors.

View File

@@ -10,23 +10,23 @@ The animation is driven by a deterministic state machine, classifier, sensor sta
## 3. How Do You Prove The Route Was Correct?
The decision tree shows thresholds and PASS/FAIL, the scene shows the selected command and route arrow, and Event Log records state, command/category and item id.
The proof card shows category, reason and `ROUTE_TO_*`. The scene highlights the selected route arrow and zone B/C/D. Event Log in Engineering Details records state, command and item id.
## 4. Where Are OZON Rules Reflected?
The rules are explicit in classification limits: min/max dimensions, roundness threshold and C priority. Criteria Coverage maps these rules to scenarios and docs.
The rules are explicit in classification limits: min/max dimensions, roundness threshold and C priority. Criteria Cards map these rules to scenarios. Full checklist is in Engineering Details.
## 5. How Does The Operator Safely Stop The System?
The `emergency_stop` scenario enters `EMERGENCY_STOP`, stops conveyor target speed and requires Reset. In Presentation Mode, Safe Demo requires confirmation before fault scenarios.
Open scenario **Аварийная остановка**. The system enters `EMERGENCY_STOP`, stops conveyor target speed and requires Reset.
## 6. What Happens On Sensor Failure Or Low Confidence?
Low confidence is logged as a warning. The system still applies deterministic dimensions/roundness rules as a fallback and shows this in the panel.
Low confidence is logged as a warning. The system still applies deterministic dimensions/roundness rules as a fallback and shows this on the proof card.
## 7. Why 2D, Not 3D?
2D is enough for MVP validation: it shows geometry, timing, sensor positions, routes and state transitions without spending effort on heavy rendering.
2D is enough for MVP validation: it shows geometry, timing, sensor positions, routes and state transitions without heavy rendering.
## 8. What Is Needed For A Physical Prototype?
@@ -38,7 +38,7 @@ The MVP uses pseudo-CV: bbox, dimensions, confidence and latency are derived fro
## 10. How Is Classification Correctness Proven?
The decision tree shows PASS/FAIL for dimensions and roundness, actual values, thresholds and final category. Tests cover key boundary cases.
The proof card shows dimensions, roundness and reason. Tests cover key boundary cases. Engineering Details has the full decision evidence and criteria checklist.
## 11. How Are Dimensions And Circular Section Handled?
@@ -50,7 +50,7 @@ Oversized or undersized items are operationally unsafe for the main line and mus
## 13. How Is Synchronization Shown?
Cycle timeline shows state order, simulated timestamps, durations and status: done, active, pending or skipped.
Storyline Stepper shows the active stage. In Engineering Details, Cycle Timeline shows state order, timestamps, durations and status: done, active, pending or skipped.
## 14. How Does The Actuator Part Work?
@@ -58,16 +58,24 @@ The stop-gate fixes the item. B opens the gate, C extends pusher C, D extends pu
## 15. What Happens On Jam?
The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required.
Open scenario **Застревание**. The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required.
## 16. Why is the interface a Product Demo Page now?
To avoid cognitive overload during the pitch. The jury needs to understand the decision and see the route clearly without distraction from raw JSON logs or PID graphs, especially on laptop projectors or mobile devices.
To avoid cognitive overload during the pitch. The jury needs to understand the decision and see the route clearly without distraction from raw logs or PID graphs on the first screen.
## 17. How does the Demo prove engineering realism?
Even in the Product Demo Section, the scene and proof card are driven by the real underlying state machine, classifier, and metric engine—not a pre-rendered video.
Even in the Product Demo Section, the scene and proof card are driven by the real state machine, classifier and metrics — not a pre-rendered video.
## 18. Where to see the full event log and PID?
Scroll down and open the "Engineering Details (Engineering Mode)" accordion.
Scroll to **Engineering Details** or click **Инженерный режим**. Open the section to see state machine, sensors, PID, timeline, event log and full criteria checklist.
## 19. How to check mobile and no horizontal scroll?
Use widths 1920×1080, 1440×900 and 390×844. On mobile the layout is one column, buttons are large, the scene scales with `width: 100%`. In console:
```js
document.documentElement.scrollWidth <= document.documentElement.clientWidth
```

View File

@@ -8,16 +8,18 @@ Before final submission:
- [ ] `https://arhipovdan.ru/` opens.
- [ ] `https://www.arhipovdan.ru/` opens.
- [ ] `https://ai-shorts.ru/` still works and was not affected.
- [ ] Presentation Mode opens.
- [ ] Demo steps checked.
- [ ] Safe Demo confirmation checked for jam/emergency.
- [ ] App is responsive on Desktop (1920x1080), Laptop (1440x900) and Mobile (390x844).
- [ ] NO horizontal scroll on body/app container.
- [ ] Product Demo layout shows step, scene, and result.
- [ ] Scenarios are displayed as cards.
- [ ] Engineering Details accordion works and shows all technical panels.
- [ ] Engineering Mode still works.
- [ ] Product Demo page opens (Hero first, not engineering dashboard).
- [ ] Start demo / Next step / Reset work.
- [ ] Storyline Stepper updates with machine state.
- [ ] Scenario cards open cases with **Показать**.
- [ ] Criteria cards show OZON coverage and linked scenarios.
- [ ] Engineering Details is collapsed by default and opens from CTA.
- [ ] Engineering Details shows full panels (state machine, sensors, PID, timeline, event log, criteria).
- [ ] App is responsive on Desktop (1920×1080), Laptop (1440×900) and Mobile (390×844).
- [ ] NO horizontal scroll on body / `#root` / product page.
- [ ] Mobile: one column, buttons ≥ 44px, scene scales.
- [ ] Scenarios checked: normal_flow, oversized_item, round_object, boundary_dimensions, close_items, low_confidence, jam, emergency_stop.
- [ ] Docs present: README, ARCHITECTURE, DEMO_SCRIPT, SCENARIOS, JURY_QA, SUBMISSION_CHECKLIST.
- [ ] Branch pushed to `origin/dan_branch`.
- [ ] Nginx backup path from deploy is known if infrastructure rollback is needed.
- [ ] Docs present: README, ARCHITECTURE, DEMO_SCRIPT, SCENARIOS, JURY_QA, UI_UX_REDESIGN_AUDIT, SUBMISSION_CHECKLIST.
- [ ] Cursor rules present in `.cursor/rules/`.
- [ ] Branch ready for manual push to `origin/dan_branch`.
- [ ] Nginx / SSL / neighboring projects were not modified.

View File

@@ -0,0 +1,58 @@
# UI/UX Redesign Audit
## 1. Что сейчас плохо
- Перегруженность: первый экран похож на engineering debug-dashboard.
- Horizontal scroll из-за `min-width: 1180px` и жёстких grid-колонок.
- Слабая адаптивность: mobile не в одну колонку, мелкие tap targets.
- Debug-dashboard вместо product demo: много панелей сразу.
- Мелкий текст (11–13px) и плотная типографика.
- Непонятный первый экран: не ясно, что это за система и как запустить демо.
## 2. Цель редизайна
Сделать чистый, адаптивный product demo для защиты хакатона: за 10 секунд понятно, что это, как запустить и какой результат показать. Инженерные панели сохранить, но спрятать ниже.
## 3. Новая информационная архитектура
1. Hero — смысл, CTA, цепочка Detection → Classification → Command → Routing
2. Product Demo — сцена + карточка результата + управление
3. Storyline Stepper — текущий этап цикла
4. Scenario Cards — jury-сценарии карточками
5. Criteria Cards — покрытие критериев OZON
6. Engineering Details — сворачиваемые техпанели
## 4. Компоненты: создать / изменить
| Компонент | Действие |
|-----------|----------|
| HeroSection | создать / доработать |
| ProductDemoSection | создать / доработать |
| StorylineStepper | создать / доработать |
| ScenarioCards | создать / доработать |
| CriteriaCards | создать / доработать |
| EngineeringDetails | создать / доработать |
| Header | упростить до product-nav |
| App | собрать product page |
| SorterScene | добавить `variant: full \| simple` |
| styles.css | responsive redesign |
## 5. В Engineering Details
StateMachinePanel, TimelinePanel, PidPanel, EventLog, MetricsPanel, SensorPanel, ScenarioPanel, OzonCriteriaPanel, SorterScene `variant="full"`.
## 6. Риски
- Не сломать domain logic / tests.
- Не потерять инженерные функции для жюри.
- Не затронуть nginx / соседние проекты.
- Сохранить обработчики start / step / reset / scenario select.
## 7. Definition of Done
- Нет horizontal scroll на 1920 / 1440 / 390.
- Hero понятен, CTA видны.
- Demo ведёт по цепочке Detection → Routing.
- Engineering Details свёрнуты по умолчанию.
- `npm run build` и `npm run test` проходят.
- Docker `owl` отдаёт страницу на `:3100` и доменах.

View File

@@ -1,24 +1,30 @@
import { useEffect, useMemo, useState } from 'react';
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 CriteriaCards from './components/CriteriaCards';
import EngineeringDetails from './components/EngineeringDetails';
import EngineeringDetails, { type EngineeringDetailsHandle } from './components/EngineeringDetails';
import { DEMO_STEPS } from './data/demoSteps';
import { SCENARIOS } from './data/scenarios';
import { createSimulation, setRunning, stepSimulationToNextState } from './domain/simulation';
import { createSimulation, stepSimulationToNextState } from './domain/simulation';
import type { ScenarioId, SimulationState } from './domain/types';
function scrollToId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
export default function App() {
const [activeScenarioId, setActiveScenarioId] = useState<ScenarioId>('normal_flow');
const [demoStepIndex, setDemoStepIndex] = useState(0);
const engineeringRef = useRef<EngineeringDetailsHandle>(null);
const activeScenario = useMemo(
() => SCENARIOS.find((s) => s.id === activeScenarioId) ?? SCENARIOS[0],
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
[activeScenarioId],
);
const currentDemoStep = DEMO_STEPS[demoStepIndex];
const [simulation, setSimulation] = useState<SimulationState>(() => createSimulation(activeScenario));
@@ -26,28 +32,30 @@ export default function App() {
setSimulation(createSimulation(activeScenario));
}, [activeScenario]);
// We are not using automatic interval in the new product demo by default to keep it step-by-step
// but if the user wants it to run automatically, we can preserve it if needed.
// Let's use handleNext to just step through.
const handleStartDemo = () => {
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
if (!simulation.currentItem && demoStepIndex === 0) {
setSimulation(stepSimulationToNextState(simulation));
}
scrollToId('demo');
setSimulation((current) => {
if (current.machineState === 'IDLE' && !current.currentItem) {
return stepSimulationToNextState(current);
}
return current;
});
};
const handleNext = () => {
// If simulation is finished or errored, we might need to reset or move to next scenario
const isFinished = simulation.machineState === 'RETURN_HOME' || simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
if (isFinished) {
const finished =
simulation.machineState === 'RETURN_HOME' ||
simulation.machineState === 'FAULT' ||
simulation.machineState === 'EMERGENCY_STOP';
if (finished) {
const nextIndex = (demoStepIndex + 1) % DEMO_STEPS.length;
setDemoStepIndex(nextIndex);
setActiveScenarioId(DEMO_STEPS[nextIndex].scenarioId);
} else {
setSimulation((current) => stepSimulationToNextState(current));
return;
}
setSimulation((current) => stepSimulationToNextState(current));
};
const handleReset = () => {
@@ -56,39 +64,66 @@ export default function App() {
const handleScenarioChange = (scenarioId: ScenarioId) => {
setActiveScenarioId(scenarioId);
// Find first step that matches this scenario
const stepIdx = DEMO_STEPS.findIndex(s => s.scenarioId === scenarioId);
if (stepIdx >= 0) setDemoStepIndex(stepIdx);
document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' });
const stepIdx = DEMO_STEPS.findIndex((step) => step.scenarioId === scenarioId);
if (stepIdx >= 0) {
setDemoStepIndex(stepIdx);
}
scrollToId('demo');
};
const handleOpenEngineering = () => {
engineeringRef.current?.open();
};
const handleShowScenarios = () => {
scrollToId('scenarios');
};
return (
<div className="product-page">
<Header simulation={simulation} />
<Header
simulation={simulation}
onReset={handleReset}
onOpenDemo={() => scrollToId('demo')}
onOpenScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
/>
<main className="product-main">
<HeroSection onStartDemo={handleStartDemo} />
<ProductDemoSection
simulation={simulation}
onNext={handleNext}
onReset={handleReset}
demoStepTitle={currentDemoStep.title}
<HeroSection
onStartDemo={handleStartDemo}
onShowScenarios={handleShowScenarios}
onOpenEngineering={handleOpenEngineering}
/>
<ScenarioCards
scenarios={SCENARIOS}
<ProductDemoSection
simulation={simulation}
demoStepTitle={currentDemoStep.title}
onStartDemo={handleStartDemo}
onNext={handleNext}
onReset={handleReset}
onOpenEngineering={handleOpenEngineering}
/>
<StorylineStepper simulation={simulation} />
<ScenarioCards
scenarios={SCENARIOS}
activeScenarioId={activeScenarioId}
onScenarioChange={handleScenarioChange}
/>
<CriteriaCards />
<EngineeringDetails simulation={simulation} />
<CriteriaCards onLinkedScenario={handleScenarioChange} />
<EngineeringDetails
ref={engineeringRef}
simulation={simulation}
onScenarioChange={handleScenarioChange}
/>
</main>
<footer className="site-footer">
<p>OZON Tech Hackathon 2026. Sorter Simulation.</p>
<p>OZON Tech Hackathon 2026 · Sorter Simulation · Product Demo</p>
</footer>
</div>
);

View File

@@ -1,27 +1,126 @@
import { OZON_CRITERIA } from '../data/criteria';
import type { ScenarioId } from '../domain/types';
export default function CriteriaCards() {
interface CriteriaCard {
id: string;
title: string;
evidence: string;
status: 'covered' | 'demo';
linkedScenario: ScenarioId;
linkedLabel: string;
}
const PRODUCT_CRITERIA: CriteriaCard[] = [
{
id: 'classification',
title: 'Классификация',
evidence: 'Rule-based дерево решений выдаёт категорию B/C/D для каждого SKU.',
status: 'covered',
linkedScenario: 'normal_flow',
linkedLabel: 'Обычный товар',
},
{
id: 'dimensions',
title: 'Габариты',
evidence: 'Проверка min/max размеров до анализа формы.',
status: 'covered',
linkedScenario: 'oversized_item',
linkedLabel: 'Негабарит',
},
{
id: 'round-section',
title: 'Круг в сечении',
evidence: 'Roundness ≥ 0.8 направляет товар в зону D.',
status: 'covered',
linkedScenario: 'round_object',
linkedLabel: 'Круглый объект',
},
{
id: 'rule-priority',
title: 'Приоритет правил',
evidence: 'Сначала габариты (C), затем форма (D), иначе основной поток B.',
status: 'covered',
linkedScenario: 'boundary_dimensions',
linkedLabel: 'Пограничные размеры',
},
{
id: 'routing',
title: 'Маршрутизация B/C/D',
evidence: 'Категория превращается в команду ROUTE_TO_* и физический маршрут.',
status: 'covered',
linkedScenario: 'normal_flow',
linkedLabel: 'Обычный товар',
},
{
id: 'cycle-time',
title: 'Cycle time',
evidence: 'Метрики показывают длительность цикла и throughput.',
status: 'covered',
linkedScenario: 'close_items',
linkedLabel: 'Очередь товаров',
},
{
id: 'faults',
title: 'Нештатные ситуации',
evidence: 'Jam переводит систему в FAULT и останавливает конвейер.',
status: 'demo',
linkedScenario: 'jam',
linkedLabel: 'Застревание',
},
{
id: 'safety',
title: 'Безопасность',
evidence: 'Emergency stop замораживает линию до Reset.',
status: 'demo',
linkedScenario: 'emergency_stop',
linkedLabel: 'Аварийная остановка',
},
{
id: 'reproducibility',
title: 'Воспроизводимость',
evidence: 'Сценарии, тесты и docs позволяют повторить демо жюри.',
status: 'covered',
linkedScenario: 'low_confidence',
linkedLabel: 'Низкая уверенность CV',
},
];
interface Props {
onLinkedScenario?: (id: ScenarioId) => void;
}
export default function CriteriaCards({ onLinkedScenario }: Props) {
return (
<section className="criteria-cards-section" id="criteria">
<section className="criteria-cards-section" id="criteria" aria-labelledby="criteria-title">
<div className="section-header">
<h2>Покрытие критериев OZON</h2>
<p>Каждый инженерный критерий хакатона покрывается симуляцией и тестами.</p>
<h2 id="criteria-title">Критерии OZON</h2>
<p>Каждый критерий хакатона закрыт сценарием, симуляцией и тестами.</p>
</div>
<div className="criteria-grid">
{OZON_CRITERIA.map((criterion) => (
<div key={criterion.id} className="criteria-card">
{PRODUCT_CRITERIA.map((criterion) => (
<article key={criterion.id} className="criteria-card">
<div className="criteria-card-header">
<h3>{criterion.title}</h3>
<span className={`status-badge ${criterion.status.replace(/ /g, '-')}`}>
{criterion.status}
<span className={`status-badge status-${criterion.status}`}>
{criterion.status === 'covered' ? 'Covered' : 'Demo'}
</span>
</div>
<p className="criteria-evidence">{criterion.evidence}</p>
<div className="criteria-footer">
<span className="related-docs">Docs: {criterion.relatedDoc}</span>
<span>Сценарий:</span>
{onLinkedScenario ? (
<button
type="button"
className="criteria-link"
onClick={() => onLinkedScenario(criterion.linkedScenario)}
>
{criterion.linkedLabel}
</button>
) : (
<strong>{criterion.linkedLabel}</strong>
)}
</div>
</div>
</article>
))}
</div>
</section>

View File

@@ -1,4 +1,3 @@
import { DIMENSION_LIMITS } from '../domain/classifier';
import type { SimulationState } from '../domain/types';
export default function CurrentProofCard({ simulation }: { simulation: SimulationState }) {
@@ -8,14 +7,19 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
const dimensions = item?.dimensionsMm;
const lowConfidence = Boolean(item && item.confidence < 0.65);
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
const command = simulation.machineState.startsWith('ROUTE_TO_')
? simulation.machineState
: result
? `ROUTE_TO_${result.category}`
: 'WAITING';
if (!current) {
return (
<div className="proof-card proof-card-empty">
<div className="proof-card-inner">
<p className="eyebrow">Awaiting cycle</p>
<h3>Ready to demonstrate sorting cycle</h3>
<p>Нажмите «Запустить демо» для начала или переключите сценарий ниже.</p>
<p className="eyebrow">Ожидание цикла</p>
<h3>Готово к демонстрации</h3>
<p>Нажмите Start demo — товар пройдёт Detection → Classification → Command → Routing.</p>
</div>
</div>
);
@@ -25,33 +29,33 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
<div className={`proof-card ${stopped ? 'proof-card-stopped' : ''}`}>
<div className="proof-card-header">
<p className="eyebrow">Результат классификации</p>
<h3>{item?.name} (ID: {item?.id})</h3>
<h3>{item?.name}</h3>
</div>
<div className="proof-grid">
<div className="proof-item">
<span className="proof-label">Категория</span>
<strong className={`category-${result?.category || 'default'}`}>{result?.category || '-'}</strong>
<strong className={`category-${result?.category ?? 'default'}`}>{result?.category ?? '—'}</strong>
</div>
<div className="proof-item">
<span className="proof-label">Маршрут</span>
<strong>{result?.category ? `Зона ${result.category}` : '-'}</strong>
<span className="proof-label">Зона</span>
<strong>{result?.category ? `Зона ${result.category}` : '—'}</strong>
</div>
<div className="proof-item">
<div className="proof-item proof-item-wide">
<span className="proof-label">Команда</span>
<strong>{simulation.machineState.startsWith('ROUTE_TO_') ? simulation.machineState : 'WAITING'}</strong>
<strong>{command}</strong>
</div>
</div>
<div className="proof-details">
<div className="detail-row">
<span>Габариты:</span>
<span>Габариты</span>
<strong className={result?.dimensionsPass === false ? 'fail-text' : ''}>
{dimensions?.width} x {dimensions?.depth} x {dimensions?.height} мм
{dimensions?.width} × {dimensions?.depth} × {dimensions?.height} мм
</strong>
</div>
<div className="detail-row">
<span>Roundness:</span>
<span>Roundness</span>
<strong className={result?.roundnessPass === false && result?.dimensionsPass ? 'fail-text' : ''}>
{item?.roundness.toFixed(2)}
</strong>
@@ -59,12 +63,15 @@ export default function CurrentProofCard({ simulation }: { simulation: Simulatio
</div>
<div className="proof-reason">
<strong>Почему такое решение:</strong> {result?.reason}
{lowConfidence && <p className="warning-note">Низкая уверенность CV — использован rule-based fallback</p>}
{simulation.machineState === 'FAULT' && <p className="error-note">FAULT: Конвейер остановлен</p>}
{simulation.machineState === 'EMERGENCY_STOP' && <p className="error-note">EMERGENCY_STOP: Аварийная остановка</p>}
{simulation.metrics.queueLength > 1 && <p className="warning-note">Внимание: очередь товаров</p>}
<strong>Причина решения</strong>
<p>{result?.reason}</p>
{lowConfidence ? <p className="warning-note">Низкая уверенность CV — rule-based fallback</p> : null}
{simulation.machineState === 'FAULT' ? <p className="error-note">FAULT: конвейер остановлен</p> : null}
{simulation.machineState === 'EMERGENCY_STOP' ? (
<p className="error-note">EMERGENCY_STOP: аварийная остановка</p>
) : null}
{simulation.metrics.queueLength > 1 ? <p className="warning-note">Очередь товаров</p> : null}
</div>
</div>
);
}
}

View File

@@ -1,36 +1,107 @@
import { useState } from 'react';
import { forwardRef, useImperativeHandle, useState } from 'react';
import StateMachinePanel from './StateMachinePanel';
import TimelinePanel from './TimelinePanel';
import PidPanel from './PidPanel';
import EventLog from './EventLog';
import MetricsPanel from './MetricsPanel';
import SensorPanel from './SensorPanel';
import type { SimulationState } from '../domain/types';
import ScenarioPanel from './ScenarioPanel';
import OzonCriteriaPanel from './OzonCriteriaPanel';
import SorterScene from './SorterScene';
import { DEMO_STEPS } from '../data/demoSteps';
import { SCENARIOS } from '../data/scenarios';
import type { ScenarioId, SimulationState } from '../domain/types';
export default function EngineeringDetails({ simulation }: { simulation: SimulationState }) {
const [isOpen, setIsOpen] = useState(false);
return (
<section className="engineering-details-section" id="engineering">
<div className="engineering-toggle" onClick={() => setIsOpen(!isOpen)}>
<h2>Инженерные детали (Engineering Mode)</h2>
<button className="btn-secondary">
{isOpen ? 'Свернуть' : 'Развернуть'}
</button>
</div>
{isOpen && (
<div className="engineering-content">
<div className="engineering-grid">
<div className="eng-panel"><StateMachinePanel currentState={simulation.machineState} /></div>
<div className="eng-panel"><MetricsPanel metrics={simulation.metrics} /></div>
<div className="eng-panel"><SensorPanel simulation={simulation} /></div>
<div className="eng-panel"><TimelinePanel simulation={simulation} /></div>
<div className="eng-panel"><PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} /></div>
<div className="eng-panel"><EventLog events={simulation.events} /></div>
</div>
</div>
)}
</section>
);
export interface EngineeringDetailsHandle {
open: () => void;
}
interface Props {
simulation: SimulationState;
onScenarioChange: (id: ScenarioId) => void;
}
const EngineeringDetails = forwardRef<EngineeringDetailsHandle, Props>(
function EngineeringDetails({ simulation, onScenarioChange }, ref) {
const [isOpen, setIsOpen] = useState(false);
const currentStep =
DEMO_STEPS.find((step) => step.scenarioId === simulation.scenario.id) ?? DEMO_STEPS[0];
useImperativeHandle(ref, () => ({
open: () => {
setIsOpen(true);
requestAnimationFrame(() => {
document.getElementById('engineering')?.scrollIntoView({ behavior: 'smooth' });
});
},
}));
return (
<section className="engineering-details-section" id="engineering" aria-labelledby="engineering-title">
<div className="engineering-toggle">
<div>
<p className="section-eyebrow">For experts</p>
<h2 id="engineering-title">Engineering Details</h2>
<p className="engineering-lead">
Полные технические панели: state machine, sensors, PID, timeline, event log и критерии.
</p>
</div>
<button
type="button"
className="btn-secondary"
onClick={() => setIsOpen((value) => !value)}
aria-expanded={isOpen}
aria-controls="engineering-content"
>
{isOpen ? 'Свернуть' : 'Открыть инженерный режим'}
</button>
</div>
{isOpen ? (
<div className="engineering-content" id="engineering-content">
<div className="eng-panel eng-panel-wide">
<SorterScene simulation={simulation} variant="full" />
</div>
<div className="engineering-grid">
<div className="eng-panel">
<StateMachinePanel currentState={simulation.machineState} />
</div>
<div className="eng-panel">
<MetricsPanel metrics={simulation.metrics} />
</div>
<div className="eng-panel">
<SensorPanel simulation={simulation} />
</div>
<div className="eng-panel">
<TimelinePanel simulation={simulation} />
</div>
<div className="eng-panel">
<PidPanel
pid={simulation.pid}
metrics={simulation.metrics}
status={simulation.systemStatus}
/>
</div>
<div className="eng-panel">
<ScenarioPanel
scenarios={SCENARIOS}
activeScenarioId={simulation.scenario.id}
onScenarioChange={onScenarioChange}
/>
</div>
<div className="eng-panel eng-panel-wide">
<EventLog events={simulation.events} />
</div>
<div className="eng-panel eng-panel-wide">
<OzonCriteriaPanel currentStep={currentStep} />
</div>
</div>
</div>
) : null}
</section>
);
},
);
export default EngineeringDetails;

View File

@@ -2,30 +2,49 @@ import type { SimulationState } from '../domain/types';
interface HeaderProps {
simulation: SimulationState;
onReset: () => void;
onOpenDemo: () => void;
onOpenScenarios: () => void;
onOpenEngineering: () => void;
}
export default function Header({ simulation }: HeaderProps) {
export default function Header({
simulation,
onReset,
onOpenDemo,
onOpenScenarios,
onOpenEngineering,
}: HeaderProps) {
return (
<header className="site-header">
<div className="header-logo">
<span className="logo-icon">O</span>
<span className="logo-icon" aria-hidden="true">
O
</span>
<div className="logo-text">
<span className="logo-title">OZON Tech</span>
<span className="logo-subtitle">Sorter Simulation</span>
<span className="logo-title">OZON Tech Sorter</span>
<span className="logo-subtitle">Product Demo</span>
</div>
</div>
<div className="header-nav">
<a href="#demo" className="nav-link">Демо</a>
<a href="#scenarios" className="nav-link">Сценарии</a>
<a href="#criteria" className="nav-link">Критерии</a>
<a href="#engineering" className="nav-link">Инженерный режим</a>
</div>
<div className="header-status-compact">
<div className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></div>
<nav className="header-nav" aria-label="Основная навигация">
<button type="button" className="nav-link" onClick={onOpenDemo}>
Демо
</button>
<button type="button" className="nav-link" onClick={onOpenScenarios}>
Сценарии
</button>
<button type="button" className="nav-link" onClick={onOpenEngineering}>
Инженерный режим
</button>
<button type="button" className="nav-link nav-reset" onClick={onReset}>
Reset
</button>
</nav>
<div className="header-status-compact" aria-live="polite">
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`} aria-hidden="true" />
<span>{simulation.systemStatus}</span>
<span className="sim-time">t+{(simulation.simTimeMs / 1000).toFixed(1)}s</span>
</div>
</header>
);

View File

@@ -1,14 +1,27 @@
export default function HeroSection({ onStartDemo }: { onStartDemo: () => void }) {
interface HeroSectionProps {
onStartDemo: () => void;
onShowScenarios: () => void;
onOpenEngineering: () => void;
}
export default function HeroSection({
onStartDemo,
onShowScenarios,
onOpenEngineering,
}: HeroSectionProps) {
return (
<section className="hero-section">
<section className="hero-section" aria-labelledby="hero-title">
<div className="hero-content">
<h1 className="hero-title">Интеллектуальная система сортировки товаров</h1>
<p className="hero-eyebrow">OZON Tech · Product Demo</p>
<h1 id="hero-title" className="hero-title">
Интеллектуальная система сортировки товаров
</h1>
<p className="hero-subtitle">
Симуляция показывает полный цикл: обнаружение товара, классификация по правилам,
управляющая команда и маршрутизация в зоны B, C, D.
Симуляция показывает полный цикл: обнаружение товара, классификация по правилам,
управляющая команда и маршрутизация в зоны B/C/D.
</p>
<div className="hero-badges">
<div className="hero-badges" aria-label="Ключевые возможности">
<span className="hero-badge">Computer Vision</span>
<span className="hero-badge">Rule-based classification</span>
<span className="hero-badge">Actuator routing</span>
@@ -17,34 +30,43 @@ export default function HeroSection({ onStartDemo }: { onStartDemo: () => void }
</div>
<div className="hero-actions">
<button className="btn-primary" onClick={onStartDemo}>Запустить демо</button>
<a href="#scenarios" className="btn-secondary">Показать сценарии</a>
<a href="#engineering" className="btn-secondary">Инженерный режим</a>
<button type="button" className="btn-primary" onClick={onStartDemo}>
Запустить демо
</button>
<button type="button" className="btn-secondary" onClick={onShowScenarios}>
Показать сценарии
</button>
<button type="button" className="btn-secondary" onClick={onOpenEngineering}>
Инженерный режим
</button>
</div>
</div>
<div className="hero-visual">
<div className="hero-visual" aria-hidden="true">
<div className="hero-chain">
<div className="chain-step">
<div className="chain-icon">📷</div>
<div className="chain-icon">1</div>
<span>Detection</span>
</div>
<div className="chain-arrow">→</div>
<div className="chain-arrow" />
<div className="chain-step">
<div className="chain-icon">🧠</div>
<div className="chain-icon">2</div>
<span>Classification</span>
</div>
<div className="chain-arrow">→</div>
<div className="chain-arrow" />
<div className="chain-step">
<div className="chain-icon">⚡</div>
<div className="chain-icon">3</div>
<span>Command</span>
</div>
<div className="chain-arrow">→</div>
<div className="chain-arrow" />
<div className="chain-step">
<div className="chain-icon">📦</div>
<div className="chain-icon">4</div>
<span>Routing</span>
</div>
</div>
<p className="hero-chain-caption">
Результат демо: категория B/C/D, команда ROUTE_TO_* и целевая зона
</p>
</div>
</section>
);

View File

@@ -1,40 +1,97 @@
import SorterScene from './SorterScene';
import CurrentProofCard from './CurrentProofCard';
import StorylineStepper from './StorylineStepper';
import type { SimulationState } from '../domain/types';
interface Props {
simulation: SimulationState;
demoStepTitle: string;
onStartDemo: () => void;
onNext: () => void;
onReset: () => void;
demoStepTitle: string;
onOpenEngineering: () => void;
}
export default function ProductDemoSection({ simulation, onNext, onReset, demoStepTitle }: Props) {
export default function ProductDemoSection({
simulation,
demoStepTitle,
onStartDemo,
onNext,
onReset,
onOpenEngineering,
}: Props) {
const item = simulation.currentItem;
const category = item?.classification.category;
const command = simulation.machineState.startsWith('ROUTE_TO_')
? simulation.machineState
: item?.classification
? `ROUTE_TO_${item.classification.category}`
: '—';
return (
<section className="product-demo-section" id="demo">
<section className="product-demo-section" id="demo" aria-labelledby="demo-title">
<div className="demo-header">
<h2>Live Demo</h2>
<div>
<p className="section-eyebrow">Live product demo</p>
<h2 id="demo-title">Главное демо</h2>
<p className="demo-lead">
Слева — упрощённая сцена конвейера. Справа — товар, категория, причина решения и команда маршрутизации.
</p>
</div>
<div className="demo-status">
Текущий шаг: <strong>{demoStepTitle}</strong>
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`}></span>
<span>Шаг:</span>
<strong>{demoStepTitle}</strong>
<span className={`status-dot ${simulation.systemStatus.toLowerCase()}`} aria-hidden="true" />
<span className="demo-status-text">{simulation.systemStatus}</span>
</div>
</div>
<StorylineStepper simulation={simulation} />
<div className="demo-layout">
<div className="demo-scene-container">
<SorterScene simulation={simulation} />
</div>
<div className="demo-proof-container">
<CurrentProofCard simulation={simulation} />
<div className="demo-controls">
<button className="btn-primary" onClick={onNext}>Следующий шаг (Next)</button>
<button className="btn-secondary" onClick={onReset}>Сброс (Reset)</button>
<div className="demo-scene-column">
<p className="demo-mobile-hint">
Товар проходит камеру, классификатор и gate, затем уходит в зону B, C или D.
</p>
<div className="demo-scene-container">
<SorterScene simulation={simulation} variant="simple" />
</div>
</div>
<aside className="demo-result-column" aria-label="Результат классификации">
<CurrentProofCard simulation={simulation} />
<div className="demo-result-summary">
<div className="summary-item">
<span>Товар</span>
<strong>{item?.item.name ?? 'Ожидание'}</strong>
</div>
<div className="summary-item">
<span>Категория</span>
<strong className={category ? `category-${category}` : ''}>{category ?? '—'}</strong>
</div>
<div className="summary-item">
<span>Команда</span>
<strong>{command}</strong>
</div>
<div className="summary-item">
<span>Зона</span>
<strong>{category ? `Зона ${category}` : '—'}</strong>
</div>
</div>
<div className="demo-controls">
<button type="button" className="btn-primary" onClick={onStartDemo}>
Start demo
</button>
<button type="button" className="btn-primary" onClick={onNext}>
Next step
</button>
<button type="button" className="btn-secondary" onClick={onReset}>
Reset
</button>
<button type="button" className="btn-secondary" onClick={onOpenEngineering}>
Engineering details
</button>
</div>
</aside>
</div>
</section>
);

View File

@@ -6,39 +6,97 @@ interface Props {
onScenarioChange: (id: ScenarioId) => void;
}
const SCENARIO_UI: Record<
ScenarioId,
{ title: string; summary: string; badge: string; badgeTone: 'b' | 'c' | 'd' | 'mixed' | 'fault' }
> = {
normal_flow: {
title: 'Обычный товар',
summary: 'Стандартный поток с маршрутизацией в B, C и D.',
badge: 'B / C / D',
badgeTone: 'mixed',
},
oversized_item: {
title: 'Негабарит',
summary: 'Габариты превышают лимит — приоритет зоны C.',
badge: 'Зона C',
badgeTone: 'c',
},
round_object: {
title: 'Круглый объект',
summary: 'Габариты проходят, но круглое сечение ведёт в D.',
badge: 'Зона D',
badgeTone: 'd',
},
boundary_dimensions: {
title: 'Пограничные размеры',
summary: 'Проверка строгих min/max границ габаритов.',
badge: 'B / C',
badgeTone: 'mixed',
},
low_confidence: {
title: 'Низкая уверенность CV',
summary: 'Предупреждение CV и rule-based fallback.',
badge: 'Fallback',
badgeTone: 'b',
},
close_items: {
title: 'Очередь товаров',
summary: 'Близкие товары обрабатываются по одному циклу.',
badge: 'Queue',
badgeTone: 'mixed',
},
jam: {
title: 'Застревание',
summary: 'Jam у gate переводит линию в FAULT.',
badge: 'FAULT',
badgeTone: 'fault',
},
emergency_stop: {
title: 'Аварийная остановка',
summary: 'EMERGENCY_STOP останавливает движение до Reset.',
badge: 'E-STOP',
badgeTone: 'fault',
},
};
export default function ScenarioCards({ scenarios, activeScenarioId, onScenarioChange }: Props) {
return (
<section className="scenario-cards-section" id="scenarios">
<section className="scenario-cards-section" id="scenarios" aria-labelledby="scenarios-title">
<div className="section-header">
<h2>Сценарии тестирования (Jury Test Cases)</h2>
<p>Выберите сценарий для проверки поведения системы в разных условиях.</p>
<h2 id="scenarios-title">Сценарии</h2>
<p>Выберите кейс жюри: обычный поток, негабарит, круглое сечение, очередь или авария.</p>
</div>
<div className="scenario-grid">
{scenarios.map((scenario) => (
<div
key={scenario.id}
className={`scenario-card ${scenario.id === activeScenarioId ? 'active' : ''}`}
onClick={() => onScenarioChange(scenario.id)}
>
<div className="scenario-card-header">
<h3>{scenario.name}</h3>
{scenario.id === activeScenarioId && <span className="active-badge">Выбран</span>}
</div>
<p className="scenario-desc">{scenario.description}</p>
<div className="scenario-meta">
<div className="meta-item">
<span className="meta-label">Цель:</span>
<span className="meta-value">{scenario.goal}</span>
{scenarios.map((scenario) => {
const ui = SCENARIO_UI[scenario.id];
const active = scenario.id === activeScenarioId;
return (
<article
key={scenario.id}
className={`scenario-card ${active ? 'active' : ''}`}
>
<div className="scenario-card-header">
<h3>{ui.title}</h3>
<span className={`result-badge tone-${ui.badgeTone}`}>{ui.badge}</span>
</div>
<div className="meta-item">
<span className="meta-label">Результат:</span>
<span className="meta-value">{scenario.expectedCategorySummary}</span>
</div>
</div>
<button className="btn-secondary btn-small mt-auto">Применить</button>
</div>
))}
<p className="scenario-desc">{ui.summary}</p>
<p className="scenario-expected">
<span>Ожидаемый результат</span>
<strong>{scenario.expectedCategorySummary}</strong>
</p>
<button
type="button"
className={active ? 'btn-primary' : 'btn-secondary'}
onClick={() => onScenarioChange(scenario.id)}
>
{active ? 'Выбран' : 'Показать'}
</button>
</article>
);
})}
</div>
</section>
);

View File

@@ -64,106 +64,172 @@ function LegendItem({ color, label }: { color: string; label: string }) {
);
}
export default function SorterScene({ simulation }: { simulation: SimulationState }) {
interface SorterSceneProps {
simulation: SimulationState;
variant?: 'full' | 'simple';
}
export default function SorterScene({ simulation, variant = 'full' }: SorterSceneProps) {
const current = simulation.currentItem;
const category = current?.classification.category;
const color = category ? routeColors[category] : '#38bdf8';
const position = itemPosition(simulation);
const detecting = simulation.machineState === 'DETECTING';
const classifying = simulation.machineState === 'CLASSIFYING' || simulation.machineState === 'WAITING_AT_GATE';
const routeVisible = simulation.machineState.startsWith('ROUTE_TO_');
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
const itemWidth = current ? Math.max(24, Math.min(74, current.item.dimensionsMm.width / 6)) : 56;
const itemHeight = current ? Math.max(18, Math.min(58, current.item.dimensionsMm.depth / 5.5)) : 48;
const isSimple = variant === 'simple';
return (
<div className="scene-wrap">
<div className="scene-title-row">
<div>
<p className="eyebrow">Work zone 6000 x 10000 mm / conveyor 500 mm</p>
<h2>Engineering layout, sensors and routing commands</h2>
<div className={`scene-wrap scene-${variant}`}>
{!isSimple ? (
<div className="scene-title-row">
<div>
<p className="eyebrow">Work zone 6000 x 10000 mm / conveyor 500 mm</p>
<h2>Engineering layout, sensors and routing commands</h2>
</div>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
) : (
<div className="scene-simple-header">
<p className="eyebrow">Конвейер · камера · classifier · gate · зоны B/C/D</p>
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
</div>
)}
<svg viewBox="0 0 1120 720" role="img" aria-label="Sorter simulation scene" className="sorter-svg">
<defs>
<pattern id="gridMinor" width="24" height="24" patternUnits="userSpaceOnUse">
<pattern id={`gridMinor-${variant}`} width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#12283b" strokeWidth="1" />
</pattern>
<pattern id="gridMajor" width="120" height="120" patternUnits="userSpaceOnUse">
<rect width="120" height="120" fill="url(#gridMinor)" />
<pattern id={`gridMajor-${variant}`} width="120" height="120" patternUnits="userSpaceOnUse">
<rect width="120" height="120" fill={`url(#gridMinor-${variant})`} />
<path d="M 120 0 L 0 0 0 120" fill="none" stroke="#244863" strokeWidth="1.4" />
</pattern>
{(['B', 'C', 'D'] as Category[]).map((route) => (
<marker key={route} id={`arrow${route}`} markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<marker key={route} id={`arrow${route}-${variant}`} markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill={routeColors[route]} />
</marker>
))}
</defs>
<rect x="24" y="26" width="1072" height="632" rx="12" fill="#07111d" stroke="#244863" />
<rect x="64" y="82" width="928" height="520" fill="url(#gridMajor)" opacity="0.9" />
<text x="78" y="74" className="scale-label left-label">Scaled plan: 6000 mm x 10000 mm work cell</text>
{!isSimple ? (
<rect x="64" y="82" width="928" height="520" fill={`url(#gridMajor-${variant})`} opacity="0.9" />
) : null}
<DimensionLine x1={64} y1={626} x2={992} y2={626} label="6000 mm work zone width" />
<DimensionLine x1={1024} y1={82} x2={1024} y2={602} label="10000 mm work zone length" />
<DimensionLine x1={92} y1={314} x2={92} y2={406} label="500 mm conveyor" />
{!isSimple ? (
<>
<text x="78" y="74" className="scale-label left-label">Scaled plan: 6000 mm x 10000 mm work cell</text>
<DimensionLine x1={64} y1={626} x2={992} y2={626} label="6000 mm work zone width" />
<DimensionLine x1={1024} y1={82} x2={1024} y2={602} label="10000 mm work zone length" />
<DimensionLine x1={92} y1={314} x2={92} y2={406} label="500 mm conveyor" />
</>
) : null}
<g className="zone zone-a">
<rect x="88" y="292" width="128" height="136" rx="8" />
<text x="152" y="282">A feed zone</text>
<text x="152" y={isSimple ? 370 : 282}>{isSimple ? 'A' : 'A feed zone'}</text>
</g>
<g className="zone zone-b">
<g className={`zone zone-b ${category === 'B' ? 'zone-active' : ''}`}>
<rect x="842" y="292" width="136" height="136" rx="8" />
<text x="910" y="282">B main sorter</text>
<text x="910" y={isSimple ? 370 : 282} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'B' : 'B main sorter'}
</text>
</g>
<g className="zone zone-d">
<g className={`zone zone-d ${category === 'D' ? 'zone-active' : ''}`}>
<rect x="642" y="112" width="210" height="124" rx="8" />
<text x="747" y="102">D roll-cage 1200 x 800 x 800 mm</text>
<text x="747" y={isSimple ? 185 : 102} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'D' : 'D roll-cage 1200 x 800 x 800 mm'}
</text>
</g>
<g className="zone zone-c">
<g className={`zone zone-c ${category === 'C' ? 'zone-active' : ''}`}>
<rect x="642" y="486" width="210" height="124" rx="8" />
<text x="747" y="632">C roll-cage 1200 x 800 x 800 mm</text>
<text x="747" y={isSimple ? 560 : 632} className={isSimple ? 'zone-label-large' : undefined}>
{isSimple ? 'C' : 'C roll-cage 1200 x 800 x 800 mm'}
</text>
</g>
<rect className={stopped ? 'conveyor stopped' : 'conveyor'} x="106" y="314" width="850" height="92" rx="6" />
<line x1="126" y1="360" x2="936" y2="360" className="belt-center" />
{Array.from({ length: 18 }).map((_, index) => (
<line key={index} x1={132 + index * 44} y1="324" x2={158 + index * 44} y2="396" className="roller-line" />
))}
{!isSimple
? Array.from({ length: 18 }).map((_, index) => (
<line key={index} x1={132 + index * 44} y1="324" x2={158 + index * 44} y2="396" className="roller-line" />
))
: null}
<g className={simulation.sensors.camera.active ? 'device active' : 'device'}>
<rect x="350" y="218" width="76" height="50" rx="6" />
<line x1="388" y1="268" x2="388" y2="314" />
<text x="388" y="208">Camera / bbox</text>
</g>
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
<rect x="474" y="218" width="76" height="50" rx="6" />
<line x1="512" y1="268" x2="512" y2="314" />
<text x="512" y="208">Laser height</text>
</g>
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<circle cx="646" cy="243" r="28" />
<line x1="646" y1="271" x2="646" y2="314" />
<text x="646" y="208">Ultrasonic gate</text>
<text x="388" y="208">{isSimple ? 'Camera' : 'Camera / bbox'}</text>
</g>
{!isSimple ? (
<>
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
<rect x="474" y="218" width="76" height="50" rx="6" />
<line x1="512" y1="268" x2="512" y2="314" />
<text x="512" y="208">Laser height</text>
</g>
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<circle cx="646" cy="243" r="28" />
<line x1="646" y1="271" x2="646" y2="314" />
<text x="646" y="208">Ultrasonic gate</text>
</g>
</>
) : (
<g className={classifying || simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<rect x="520" y="218" width="110" height="50" rx="6" />
<line x1="575" y1="268" x2="575" y2="314" />
<text x="575" y="208">Classifier</text>
</g>
)}
<g className={simulation.gate.open ? 'gate open' : 'gate closed'}>
<line x1="708" y1="296" x2="708" y2="424" />
<text x="746" y="300">Stop-gate {simulation.gate.open ? 'open' : 'closed'}</text>
<text x="746" y="300">{isSimple ? 'Gate' : `Stop-gate ${simulation.gate.open ? 'open' : 'closed'}`}</text>
</g>
<g className={`pusher ${simulation.actuators.pusherC}`}>
<rect x="625" y="424" width="174" height="34" rx="6" />
<text x="712" y="476">Pusher C command</text>
</g>
<g className={`pusher ${simulation.actuators.pusherD}`}>
<rect x="625" y="262" width="174" height="34" rx="6" />
<text x="712" y="256">Pusher D command</text>
</g>
{!isSimple ? (
<>
<g className={`pusher ${simulation.actuators.pusherC}`}>
<rect x="625" y="424" width="174" height="34" rx="6" />
<text x="712" y="476">Pusher C command</text>
</g>
<g className={`pusher ${simulation.actuators.pusherD}`}>
<rect x="625" y="262" width="174" height="34" rx="6" />
<text x="712" y="256">Pusher D command</text>
</g>
</>
) : null}
<line
x1="706"
y1="360"
x2="928"
y2="360"
className={`route-guide route-b ${category === 'B' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowB-${variant})`}
/>
<line
x1="706"
y1="376"
x2="748"
y2="548"
className={`route-guide route-c ${category === 'C' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowC-${variant})`}
/>
<line
x1="706"
y1="344"
x2="748"
y2="174"
className={`route-guide route-d ${category === 'D' && routeVisible ? 'route-active' : ''}`}
markerEnd={`url(#arrowD-${variant})`}
/>
<line x1="706" y1="360" x2="928" y2="360" className="route-guide route-b" markerEnd="url(#arrowB)" />
<line x1="706" y1="376" x2="748" y2="548" className="route-guide route-c" markerEnd="url(#arrowC)" />
<line x1="706" y1="344" x2="748" y2="174" className="route-guide route-d" markerEnd="url(#arrowD)" />
{routeVisible && category ? (
<g className="route-command">
<rect x="788" y="326" width="156" height="34" rx="8" fill={routeColors[category]} />
@@ -184,11 +250,22 @@ export default function SorterScene({ simulation }: { simulation: SimulationStat
stroke="#ffffff"
strokeWidth="1.4"
/>
<text x={position.x} y={position.y + itemHeight / 2 + 18} className="item-label">{current.item.id}</text>
<text x={position.x} y={position.y + itemHeight / 2 + 18} className="item-label">
{isSimple ? current.item.name : current.item.id}
</text>
{detecting ? (
<g className="bbox">
<rect x={position.x - itemWidth / 2 - 10} y={position.y - itemHeight / 2 - 10} width={itemWidth + 20} height={itemHeight + 20} />
<text x={position.x} y={position.y - itemHeight / 2 - 18}>bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm</text>
<rect
x={position.x - itemWidth / 2 - 10}
y={position.y - itemHeight / 2 - 10}
width={itemWidth + 20}
height={itemHeight + 20}
/>
{!isSimple ? (
<text x={position.x} y={position.y - itemHeight / 2 - 18}>
bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm
</text>
) : null}
</g>
) : null}
</g>
@@ -202,13 +279,23 @@ export default function SorterScene({ simulation }: { simulation: SimulationStat
</g>
) : null}
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B main sorter" />
<g transform="translate(140 0)"><LegendItem color="#f59e0b" label="C oversize" /></g>
<g transform="translate(270 0)"><LegendItem color="#c084fc" label="D shape / repack" /></g>
<g transform="translate(430 0)"><LegendItem color="#38bdf8" label="camera / laser / ultrasonic active" /></g>
<g transform="translate(700 0)"><LegendItem color="#fb3d4e" label="stop-gate / fault" /></g>
</g>
{!isSimple ? (
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B main sorter" />
<g transform="translate(140 0)"><LegendItem color="#f59e0b" label="C oversize" /></g>
<g transform="translate(270 0)"><LegendItem color="#c084fc" label="D shape / repack" /></g>
<g transform="translate(430 0)"><LegendItem color="#38bdf8" label="camera / laser / ultrasonic active" /></g>
<g transform="translate(700 0)"><LegendItem color="#fb3d4e" label="stop-gate / fault" /></g>
</g>
) : (
<g className="scene-legend" transform="translate(78 664)">
<LegendItem color="#4ade80" label="B" />
<g transform="translate(70 0)"><LegendItem color="#f59e0b" label="C" /></g>
<g transform="translate(140 0)"><LegendItem color="#c084fc" label="D" /></g>
<g transform="translate(210 0)"><LegendItem color="#38bdf8" label="active" /></g>
<g transform="translate(320 0)"><LegendItem color="#fb3d4e" label="fault" /></g>
</g>
)}
</svg>
</div>
);

View File

@@ -1,65 +1,76 @@
import type { SimulationState } from '../domain/types';
import type { MachineState, SimulationState } from '../domain/types';
type StepKey = 'detection' | 'classification' | 'decision' | 'command' | 'routing';
type StepStatus = 'active' | 'completed' | 'pending';
const STEPS: Array<{ key: StepKey; number: number; label: string; description: string }> = [
{ key: 'detection', number: 1, label: 'Detection', description: 'Камера и датчики снимают габариты' },
{ key: 'classification', number: 2, label: 'Classification', description: 'Правила проверяют размеры и форму' },
{ key: 'decision', number: 3, label: 'Decision', description: 'Система выбирает категорию B/C/D' },
{ key: 'command', number: 4, label: 'Command', description: 'Формируется команда ROUTE_TO_*' },
{ key: 'routing', number: 5, label: 'Routing', description: 'Товар уходит в целевую зону' },
];
function activeStep(state: MachineState): StepKey | null {
if (state === 'IDLE') return null;
if (state === 'MOVING_TO_CAMERA' || state === 'DETECTING') return 'detection';
if (state === 'MOVING_TO_GATE' || state === 'WAITING_AT_GATE') return 'classification';
if (state === 'CLASSIFYING') return 'decision';
if (state.startsWith('ROUTE_TO_')) return 'command';
if (state === 'RETURN_HOME') return 'routing';
if (state === 'FAULT' || state === 'EMERGENCY_STOP') return 'routing';
return null;
}
function stepStatus(key: StepKey, current: StepKey | null, machineState: MachineState): StepStatus {
if (machineState === 'IDLE') return 'pending';
const order: StepKey[] = ['detection', 'classification', 'decision', 'command', 'routing'];
const currentIndex = current ? order.indexOf(current) : -1;
const keyIndex = order.indexOf(key);
if (machineState === 'RETURN_HOME') {
return keyIndex < order.length - 1 ? 'completed' : 'active';
}
if (machineState === 'FAULT' || machineState === 'EMERGENCY_STOP') {
return keyIndex <= currentIndex ? 'active' : 'pending';
}
if (keyIndex < currentIndex) return 'completed';
if (keyIndex === currentIndex) return 'active';
return 'pending';
}
export default function StorylineStepper({ simulation }: { simulation: SimulationState }) {
const state = simulation.machineState;
const isDetection = state === 'MOVING_TO_CAMERA' || state === 'DETECTING';
const isClassification = state === 'MOVING_TO_GATE' || state === 'WAITING_AT_GATE' || state === 'CLASSIFYING';
const isCommand = state.startsWith('ROUTE_TO_');
const isRouting = state === 'RETURN_HOME';
const item = simulation.currentItem;
return (
<div className="storyline-stepper">
<Step
label="1. Detection"
desc="Распознавание габаритов"
active={isDetection}
done={Boolean(item) && !isDetection}
/>
<Step
label="2. Classification"
desc="Дерево решений"
active={isClassification}
done={Boolean(item?.classification) && !isClassification && !isDetection}
/>
<Step
label="3. Decision"
desc="Выбор категории"
active={isClassification && state === 'CLASSIFYING'}
done={Boolean(item?.classification)}
/>
<Step
label="4. Command"
desc="Сигнал механизмам"
active={isCommand}
done={isRouting}
/>
<Step
label="5. Routing"
desc="Движение в зону"
active={isCommand || isRouting}
done={isRouting}
/>
</div>
);
}
function Step({ label, desc, active, done }: { label: string, desc: string, active: boolean, done: boolean }) {
let statusClass = 'step-pending';
if (active) statusClass = 'step-active';
else if (done) statusClass = 'step-done';
const current = activeStep(simulation.machineState);
return (
<div className={`story-step ${statusClass}`}>
<div className="step-indicator">
<div className="step-dot"></div>
<section className="storyline-section" id="storyline" aria-labelledby="storyline-title">
<div className="section-header">
<h2 id="storyline-title">Этапы цикла</h2>
<p>Текущий этап системы в цепочке Detection → Classification → Decision → Command → Routing.</p>
</div>
<div className="step-content">
<strong>{label}</strong>
<span>{desc}</span>
</div>
</div>
<ol className="storyline-stepper">
{STEPS.map((step) => {
const status = stepStatus(step.key, current, simulation.machineState);
return (
<li key={step.key} className={`story-step step-${status}`}>
<div className="step-indicator" aria-hidden="true">
<span className="step-number">{step.number}</span>
</div>
<div className="step-content">
<strong>{step.label}</strong>
<span>{step.description}</span>
<span className="step-status-label">
{status === 'active' ? 'Сейчас' : status === 'completed' ? 'Готово' : 'Ожидание'}
</span>
</div>
</li>
);
})}
</ol>
</section>
);
}

File diff suppressed because it is too large Load Diff