diff --git a/README.md b/README.md
index 8ce5f03..a19f2fa 100644
--- a/README.md
+++ b/README.md
@@ -2,91 +2,20 @@
Инженерный frontend MVP для задачи хакатона OZON Tech: «Интеллектуальная роботизированная система сортировки товаров».
-Приложение показывает 2D/SVG dashboard сортировочной линии: конвейер, рабочую зону 6000 x 10000 мм, зоны A/B/C/D, датчики, stop-gate, боковые толкатели, движение товаров, классификацию, маршрутизацию, state machine, метрики, PID-виджет и event log.
+Проект показывает 2D/SVG dashboard сортировочной линии: рабочую зону 6000 x 10000 мм, конвейер шириной 500 мм, зоны A/B/C/D, roll-cage C/D, датчики, stop-gate, толкатели, классификацию, маршрутизацию, timeline цикла, PID-имитацию, метрики и event log.
-## Архитектура решения
+## Стек
-- Vite + React + TypeScript.
-- Доменная логика вынесена в `src/domain`.
-- Mock-данные товаров и сценариев находятся в `src/data`.
-- UI собран из независимых панелей в `src/components`.
-- Визуализация линии выполнена на SVG без Three.js и тяжелой 3D-графики.
+- Vite
+- React
+- TypeScript
+- SVG/CSS для инженерной 2D-сцены
+- Vitest для доменных тестов
+- Docker + nginx для production static hosting
-## Логика классификации
+Нет Three.js, backend, реального ML и тяжелых UI-библиотек.
-Классификация реализована чистой функцией `classifyItem`.
-
-1. Сначала проверяются допустимые габариты.
-2. Если товар нарушает min/max размеры, он относится к C.
-3. Если габариты подходят, проверяется `roundness`.
-4. Если `roundness >= 0.8`, товар относится к D.
-5. Иначе товар относится к B.
-6. Если объект одновременно негабаритный и круглый, приоритет остается у C.
-
-Допустимые габариты MVP:
-
-- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм;
-- max: width <= 450 мм, depth <= 320 мм, height <= 320 мм;
-- roundness threshold: 0.8.
-
-## Исполнительная часть
-
-Симуляция отображает:
-
-- камеру с pseudo-CV, bounding box и confidence;
-- лазерный датчик высоты;
-- ультразвуковой датчик прибытия к stop-gate;
-- stop-gate с состояниями open/closed;
-- pusher C и pusher D со состояниями idle/extended/retracting;
-- маршруты B/C/D с цветовой индикацией;
-- предупреждения для close items и low confidence;
-- fault/emergency сценарии с остановкой конвейера.
-
-## State machine
-
-Состояния MVP:
-
-- IDLE
-- MOVING_TO_CAMERA
-- DETECTING
-- MOVING_TO_GATE
-- WAITING_AT_GATE
-- CLASSIFYING
-- ROUTE_TO_B
-- ROUTE_TO_C
-- ROUTE_TO_D
-- RETURN_HOME
-- FAULT
-- EMERGENCY_STOP
-
-## Сценарии
-
-- `normal_flow` — обычный поток товаров B/C/D.
-- `oversized_item` — товар выходит за max dimensions и уходит в C.
-- `round_object` — товар проходит по габаритам, но имеет roundness >= 0.8 и уходит в D.
-- `boundary_dimensions` — товары на границах допустимых размеров.
-- `close_items` — два товара близко друг к другу, появляется warning queue/spacing.
-- `low_confidence` — confidence ниже 0.65, решение принимается rule-based.
-- `jam` — застревание у stop-gate, state FAULT, conveyor stopped.
-- `emergency_stop` — аварийная остановка, state EMERGENCY_STOP.
-
-## Метрики
-
-Dashboard показывает:
-
-- processed count;
-- success count;
-- error count;
-- avg cycle time;
-- throughput items/min;
-- cv latency;
-- actuator latency;
-- queue length;
-- conveyor speed;
-- PID target speed;
-- PID actual speed.
-
-## Локальный запуск
+## Запуск локально
```bash
npm install
@@ -95,7 +24,7 @@ npm run dev
Открыть: http://127.0.0.1:3100/
-## Запуск на сервере
+## Запуск через Docker
```bash
docker compose -p owl -f docker-compose.server.yml up -d --build
@@ -107,25 +36,97 @@ Compose публикует только loopback-порт:
127.0.0.1:3100:80
```
-## Как проверить
+## Проверка домена
```bash
curl -I http://127.0.0.1:3100/
+curl -I https://arhipovdan.ru/
+curl -I https://www.arhipovdan.ru/
```
+## Сценарии
+
+- `normal_flow` — обычный поток B/C/D.
+- `oversized_item` — max dimensions нарушены, маршрут C.
+- `round_object` — габариты проходят, roundness >= 0.8, маршрут D.
+- `boundary_dimensions` — проверка min/max границ.
+- `close_items` — предупреждение spacing/queue, последовательная обработка.
+- `low_confidence` — низкий CV confidence, rule-based fallback.
+- `jam` — застревание у gate, FAULT, остановка конвейера.
+- `emergency_stop` — EMERGENCY_STOP, остановка всех движений.
+
+## Классификация
+
+Классификация реализована чистой функцией `classifyItem`.
+
+1. Проверяются габариты.
+2. Если нарушены min/max размеры, категория C.
+3. Если габариты подходят, проверяется `roundness`.
+4. Если `roundness >= 0.8`, категория D.
+5. Иначе категория B.
+6. Если товар одновременно негабаритный и круглый, приоритет у C, потому что dimensions check идет первым.
+
+Границы MVP:
+
+- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм;
+- max: width <= 450 мм, depth <= 320 мм, height <= 320 мм;
+- roundness threshold: 0.8.
+
+## Исполнительная часть
+
+State machine управляет циклом:
+
+- `MOVING_TO_CAMERA`
+- `DETECTING`
+- `MOVING_TO_GATE`
+- `WAITING_AT_GATE`
+- `CLASSIFYING`
+- `ROUTE_TO_B/C/D`
+- `RETURN_HOME`
+- `FAULT`
+- `EMERGENCY_STOP`
+
+Датчики имитируются по mock-данным: camera bbox/confidence/CV latency, laser measured height, ultrasonic gate detection. Stop-gate закрывается перед классификацией, открывается для B и удерживает товар для C/D перед толкателями.
+
+## Simplified PID
+
+PID-панель показывает упрощенную имитацию control loop:
+
+- target speed;
+- actual speed;
+- error;
+- correction;
+- mini graph последних тиков скорости.
+
+В normal flow actual speed приближается к target. В `jam` и `emergency_stop` target становится 0, actual speed визуально падает к 0.
+
+## Тесты
+
+```bash
+npm run test
+```
+
+Покрыты classifier, PID и целостность сценариев.
+
+## Документация
+
+- `docs/ARCHITECTURE.md` — модули и поток данных.
+- `docs/DEMO_SCRIPT.md` — 3-5 минутный сценарий защиты.
+- `docs/SCENARIOS.md` — ожидаемые результаты сценариев.
+- `docs/JURY_QA.md` — короткие ответы на вопросы жюри.
+
## Ограничения MVP
-- Физика движения упрощена до дискретной state-machine симуляции.
-- PID-регулятор имитационный, без точной модели двигателя и нагрузки.
-- CV, laser и ultrasound генерируют псевдоизмерения на основе mock-данных.
-- Нет backend, real-time API и сохранения событий.
-- Нет Three.js и полноценного digital twin в 3D.
+- Физика движения дискретная, без динамической модели массы/трения.
+- CV является pseudo-CV по mock-данным.
+- PID упрощен до демонстрации стабилизации скорости.
+- Нет backend, real-time API, сохранения событий и реального ML.
+- Нет 3D digital twin.
## Что улучшить дальше
-- Добавить backend с WebSocket-телеметрией.
-- Подключить реальные ML/CV модели или replay датасетов.
-- Сделать режим ручного управления исполнительными механизмами.
-- Добавить тесты доменной логики и сценариев.
-- Расширить физическую модель очереди, зазоров и jam recovery.
-- Добавить экспорт event log и отчеты по SLA сортировки.
+- Добавить WebSocket-телеметрию и replay реальных событий.
+- Подключить реальные CV-модели или датасеты.
+- Добавить режим manual override для gate/pushers.
+- Расширить модель очереди, spacing и recovery после jam.
+- Экспортировать event log в отчет смены.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..035ef82
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,71 @@
+# Architecture
+
+## Module Map
+
+```text
+src/data/items.ts
+src/data/scenarios.ts
+ |
+ v
+src/domain/classifier.ts -> src/domain/simulation.ts -> React UI components
+ | |
+ | +-> metrics, PID, sensors, event log
+ v
+classification result
+```
+
+## Data Flow
+
+1. Scenario selects a sequence of mock items.
+2. Simulation feeds one item at a time into zone A.
+3. Pseudo-CV and sensors derive measurements from item data.
+4. `classifyItem` applies deterministic rules.
+5. State machine commands gate and pushers.
+6. UI renders SVG scene, panels, metrics, timeline and event log.
+
+## Pseudo-CV
+
+The MVP does not run real ML. Camera output is generated from item dimensions:
+
+- bbox width/depth;
+- confidence;
+- CV latency;
+- detected dimensions.
+
+Low confidence is not ignored: the event log and classification panel show a warning and explain rule-based fallback.
+
+## Sensor Simulation
+
+- Camera is active in `DETECTING`.
+- Laser is active during `DETECTING` and `MOVING_TO_GATE`.
+- Ultrasonic sensor is active at `WAITING_AT_GATE` and `CLASSIFYING`.
+
+Each sensor keeps active state, last value, latency and last event timestamp.
+
+## Actuator Control
+
+- Stop-gate closes at `WAITING_AT_GATE`.
+- B route opens the gate and sends item straight.
+- C route keeps the gate closed and extends pusher C.
+- D route keeps the gate closed and extends pusher D.
+- `RETURN_HOME` retracts mechanisms.
+
+## State Machine
+
+Main cycle:
+
+```text
+IDLE -> MOVING_TO_CAMERA -> DETECTING -> MOVING_TO_GATE -> WAITING_AT_GATE
+-> CLASSIFYING -> ROUTE_TO_B/C/D -> RETURN_HOME -> next item or IDLE
+```
+
+Fault states:
+
+- `FAULT` for jam at gate;
+- `EMERGENCY_STOP` for emergency stop scenario.
+
+Both stop conveyor motion and require Reset.
+
+## Metrics
+
+The dashboard tracks processed count, success/error count, avg cycle time, throughput, CV latency, actuator latency, queue length, queue delay and conveyor speed.
diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md
new file mode 100644
index 0000000..dc56fc6
--- /dev/null
+++ b/docs/DEMO_SCRIPT.md
@@ -0,0 +1,69 @@
+# Demo Script
+
+Target duration: 3-5 minutes.
+
+## 1. Open The Dashboard
+
+Open https://arhipovdan.ru/.
+
+Explain that this is a 2D engineering simulation, not a decorative animation: the SVG scene shows a scaled work zone, conveyor dimensions, sensors, stop-gate, pushers and roll-cages.
+
+## 2. Normal Flow
+
+Select `Normal flow`, press `Start`.
+
+Explain:
+
+- item enters zone A;
+- camera captures bbox;
+- laser measures height;
+- ultrasonic confirms gate position;
+- gate holds item;
+- classifier selects B/C/D;
+- route command is shown on the scene;
+- event log records the full cycle.
+
+Question closed: can the system show a full sorting cycle end-to-end?
+
+## 3. Step-By-Step Decision
+
+Press `Reset`, then use `Step state`.
+
+Explain each state in the timeline. Show that Step advances by logical state, not by arbitrary animation time.
+
+Question closed: can the jury inspect synchronization and state transitions?
+
+## 4. Oversized And Round Rules
+
+Select `Oversized item`, step to classification.
+
+Show decision tree:
+
+- dimensions check fails;
+- category C selected;
+- roundness is lower priority because dimensions are checked first.
+
+Then select `Round object` and show D when dimensions pass and roundness >= 0.8.
+
+Question closed: how is classification proved?
+
+## 5. Robustness Scenarios
+
+Show `close_items`, `low_confidence`, `jam`, `emergency_stop`.
+
+Explain:
+
+- close items generate spacing warning and queue length;
+- low confidence uses rule-based fallback;
+- jam enters FAULT and stops conveyor;
+- emergency stop enters EMERGENCY_STOP and requires Reset.
+
+Question closed: what happens outside the happy path?
+
+## 6. PID And Metrics
+
+Point to PID panel and metrics cards.
+
+Explain that PID is simplified: actual speed approaches target in normal flow and decays toward zero in fault/emergency.
+
+Question closed: how is conveyor control represented without overbuilding physics?
diff --git a/docs/JURY_QA.md b/docs/JURY_QA.md
new file mode 100644
index 0000000..749f0de
--- /dev/null
+++ b/docs/JURY_QA.md
@@ -0,0 +1,41 @@
+# Jury Q&A
+
+## 1. 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.
+
+## 2. Where Is Computer Vision?
+
+The MVP uses pseudo-CV: bbox, dimensions, confidence and latency are derived from mock items. The architecture keeps CV output separate from classification, so a real CV service can replace it later.
+
+## 3. 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.
+
+## 4. How Are Dimensions And Circular Section Handled?
+
+Dimensions are checked first against min/max width, depth and height. If they pass, roundness is checked against threshold 0.8.
+
+## 5. Why Does C Have Priority Over D?
+
+Oversized or undersized items are operationally unsafe for the main line and must be diverted first. Therefore dimensions check precedes roundness.
+
+## 6. How Is Synchronization Shown?
+
+Cycle timeline shows state order, simulated timestamps, durations and status: done, active, pending or skipped.
+
+## 7. How Does The Actuator Part Work?
+
+The stop-gate fixes the item. B opens the gate, C extends pusher C, D extends pusher D, then mechanisms return home.
+
+## 8. What Happens On Jam?
+
+The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required.
+
+## 9. What Happens On Low CV Confidence?
+
+A warning is logged, but the system still classifies by deterministic dimensions and roundness rules.
+
+## 10. How To Scale This To A Real Hardware-Software System?
+
+Replace pseudo-CV with a CV service, connect PLC/robot telemetry via backend/WebSocket, persist event logs, calibrate sensor latencies and add recovery policies.
diff --git a/docs/SCENARIOS.md b/docs/SCENARIOS.md
new file mode 100644
index 0000000..a00d048
--- /dev/null
+++ b/docs/SCENARIOS.md
@@ -0,0 +1,49 @@
+# Scenarios
+
+## normal_flow
+
+Goal: demonstrate the full cycle across B/C/D routes.
+
+Expected result: mixed B, C and D items are processed successfully.
+
+## oversized_item
+
+Goal: prove dimensions have first priority.
+
+Expected result: every item routes to C.
+
+## round_object
+
+Goal: prove roundness check after dimensions.
+
+Expected result: every item routes to D.
+
+## boundary_dimensions
+
+Goal: test min/max boundaries.
+
+Expected result: `Boundary box 450x320x320` routes to B, `Pen 9x13x148` routes to C.
+
+## close_items
+
+Goal: demonstrate queue/spacing resilience.
+
+Expected result: warning appears, queue length is shown, items are processed sequentially.
+
+## low_confidence
+
+Goal: demonstrate fallback when pseudo-CV confidence is below 0.65.
+
+Expected result: warning appears, rule-based classification still selects B or D.
+
+## jam
+
+Goal: demonstrate fail-safe behavior at stop-gate.
+
+Expected result: state becomes FAULT, conveyor speed target is 0, Reset is required.
+
+## emergency_stop
+
+Goal: demonstrate emergency stop.
+
+Expected result: state becomes EMERGENCY_STOP, conveyor speed target is 0, Reset is required.
diff --git a/package-lock.json b/package-lock.json
index 8feefda..fa008e6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,8 @@
"devDependencies": {
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
- "@types/react-dom": "^19.2.3"
+ "@types/react-dom": "^19.2.3",
+ "vitest": "^4.1.9"
}
},
"node_modules/@emnapi/core": {
@@ -51,6 +52,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -326,6 +334,13 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -336,6 +351,31 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
@@ -391,6 +431,146 @@
}
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
+ "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
+ "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
+ "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
+ "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.9",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
+ "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
+ "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
+ "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.9",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -407,6 +587,33 @@
"node": ">=8"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
+ "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -687,6 +894,16 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -705,6 +922,27 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -811,6 +1049,13 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -820,6 +1065,37 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -836,6 +1112,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -939,6 +1225,113 @@
"optional": true
}
}
+ },
+ "node_modules/vitest": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
+ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.9",
+ "@vitest/mocker": "4.1.9",
+ "@vitest/pretty-format": "4.1.9",
+ "@vitest/runner": "4.1.9",
+ "@vitest/snapshot": "4.1.9",
+ "@vitest/spy": "4.1.9",
+ "@vitest/utils": "4.1.9",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.9",
+ "@vitest/browser-preview": "4.1.9",
+ "@vitest/browser-webdriverio": "4.1.9",
+ "@vitest/coverage-istanbul": "4.1.9",
+ "@vitest/coverage-v8": "4.1.9",
+ "@vitest/ui": "4.1.9",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
}
}
}
diff --git a/package.json b/package.json
index 23ab5f1..f4632e1 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite --host 127.0.0.1 --port 3100",
"build": "tsc -b && vite build",
- "preview": "vite preview --host 127.0.0.1 --port 3100"
+ "preview": "vite preview --host 127.0.0.1 --port 3100",
+ "test": "vitest run"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
@@ -18,6 +19,7 @@
"devDependencies": {
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
- "@types/react-dom": "^19.2.3"
+ "@types/react-dom": "^19.2.3",
+ "vitest": "^4.1.9"
}
}
diff --git a/src/App.tsx b/src/App.tsx
index 335aa4a..a1c8932 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import Dashboard from './components/Dashboard';
import { SCENARIOS } from './data/scenarios';
-import { createSimulation, setRunning, stepSimulation } from './domain/simulation';
+import { createSimulation, setRunning, stepSimulation, stepSimulationToNextState } from './domain/simulation';
import type { ScenarioId, SimulationState } from './domain/types';
const TICK_MS = 250;
@@ -33,7 +33,7 @@ export default function App() {
const handleStart = () => setSimulation((current) => setRunning(current, true));
const handlePause = () => setSimulation((current) => setRunning(current, false));
const handleReset = () => setSimulation(createSimulation(activeScenario));
- const handleStep = () => setSimulation((current) => stepSimulation(setRunning(current, false), 500, true));
+ const handleStep = () => setSimulation((current) => stepSimulationToNextState(current));
const handleScenarioChange = (scenarioId: ScenarioId) => setActiveScenarioId(scenarioId);
return (
diff --git a/src/components/ClassificationPanel.tsx b/src/components/ClassificationPanel.tsx
index 2e5af88..64ea8d7 100644
--- a/src/components/ClassificationPanel.tsx
+++ b/src/components/ClassificationPanel.tsx
@@ -1,26 +1,48 @@
import { DIMENSION_LIMITS } from '../domain/classifier';
-import type { SimulatedItem } from '../domain/types';
+import type { SimulationState } from '../domain/types';
-export default function ClassificationPanel({ currentItem }: { currentItem?: SimulatedItem }) {
- const result = currentItem?.classification;
+function Verdict({ pass, skipped = false }: { pass?: boolean; skipped?: boolean }) {
+ if (skipped) {
+ return SKIPPED;
+ }
+ if (pass === undefined) {
+ return PENDING;
+ }
+ return {pass ? 'PASS' : 'FAIL'};
+}
+
+export default function ClassificationPanel({ simulation }: { simulation: SimulationState }) {
+ const current = simulation.currentItem;
+ const result = current?.classification;
+ const item = current?.item;
+ const dimensions = item?.dimensionsMm;
+ const isOversizedAndRound = Boolean(result && !result.dimensionsPass && !result.roundnessPass);
+ const lowConfidence = Boolean(item && item.confidence < 0.65);
return (
-
+
Classification rules
-
Decision tree
+
Decision proof
+
+
+
+
Step 1: Dimensions check
+
Allowed: W {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} mm, D {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} mm, H {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} mm.
+
Actual: {dimensions ? `${dimensions.width} x ${dimensions.depth} x ${dimensions.height} mm` : 'waiting for item'}
+
+
+
Step 2: Roundness check
+
Roundness threshold for D: value >= {DIMENSION_LIMITS.roundnessThreshold}.
+
Actual roundness: {item ? item.roundness.toFixed(2) : 'waiting for item'}