diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5bd6f15
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+.git
+npm-debug.log
+.DS_Store
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8f6a8d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+*.tsbuildinfo
+vite.config.js
+vite.config.d.ts
+.env
+.env.*
+npm-debug.log*
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8123798
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:20-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine AS runtime
+COPY --from=build /app/dist /usr/share/nginx/html
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/README.md b/README.md
index e69de29..8ce5f03 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,131 @@
+# OZON Tech Sorter Simulation
+
+Инженерный frontend MVP для задачи хакатона OZON Tech: «Интеллектуальная роботизированная система сортировки товаров».
+
+Приложение показывает 2D/SVG dashboard сортировочной линии: конвейер, рабочую зону 6000 x 10000 мм, зоны A/B/C/D, датчики, stop-gate, боковые толкатели, движение товаров, классификацию, маршрутизацию, state machine, метрики, PID-виджет и event log.
+
+## Архитектура решения
+
+- Vite + React + TypeScript.
+- Доменная логика вынесена в `src/domain`.
+- Mock-данные товаров и сценариев находятся в `src/data`.
+- UI собран из независимых панелей в `src/components`.
+- Визуализация линии выполнена на SVG без Three.js и тяжелой 3D-графики.
+
+## Логика классификации
+
+Классификация реализована чистой функцией `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
+npm run dev
+```
+
+Открыть: http://127.0.0.1:3100/
+
+## Запуск на сервере
+
+```bash
+docker compose -p owl -f docker-compose.server.yml up -d --build
+```
+
+Compose публикует только loopback-порт:
+
+```yaml
+127.0.0.1:3100:80
+```
+
+## Как проверить
+
+```bash
+curl -I http://127.0.0.1:3100/
+```
+
+## Ограничения MVP
+
+- Физика движения упрощена до дискретной state-machine симуляции.
+- PID-регулятор имитационный, без точной модели двигателя и нагрузки.
+- CV, laser и ultrasound генерируют псевдоизмерения на основе mock-данных.
+- Нет backend, real-time API и сохранения событий.
+- Нет Three.js и полноценного digital twin в 3D.
+
+## Что улучшить дальше
+
+- Добавить backend с WebSocket-телеметрией.
+- Подключить реальные ML/CV модели или replay датасетов.
+- Сделать режим ручного управления исполнительными механизмами.
+- Добавить тесты доменной логики и сценариев.
+- Расширить физическую модель очереди, зазоров и jam recovery.
+- Добавить экспорт event log и отчеты по SLA сортировки.
diff --git a/docker-compose.server.yml b/docker-compose.server.yml
new file mode 100644
index 0000000..1b27b1c
--- /dev/null
+++ b/docker-compose.server.yml
@@ -0,0 +1,6 @@
+services:
+ web:
+ build: .
+ ports:
+ - "127.0.0.1:3100:80"
+ restart: unless-stopped
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..a543af6
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ OZON Tech Sorter Simulation
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8feefda
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,944 @@
+{
+ "name": "ozon-tech-sorter-simulation",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ozon-tech-sorter-simulation",
+ "version": "0.1.0",
+ "dependencies": {
+ "@vitejs/plugin-react": "latest",
+ "react": "latest",
+ "react-dom": "latest",
+ "typescript": "latest",
+ "vite": "latest"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.0",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "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",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.138.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
+ "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
+ "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
+ "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
+ "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
+ "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
+ "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
+ "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
+ "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
+ "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
+ "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
+ "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
+ "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
+ "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
+ "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
+ "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
+ "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "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",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
+ "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
+ "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.138.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.4",
+ "@rolldown/binding-darwin-arm64": "1.1.4",
+ "@rolldown/binding-darwin-x64": "1.1.4",
+ "@rolldown/binding-freebsd-x64": "1.1.4",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.4",
+ "@rolldown/binding-linux-arm64-musl": "1.1.4",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-musl": "1.1.4",
+ "@rolldown/binding-openharmony-arm64": "1.1.4",
+ "@rolldown/binding-wasm32-wasi": "1.1.4",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.4",
+ "@rolldown/binding-win32-x64-msvc": "1.1.4"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.16",
+ "rolldown": "~1.1.3",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..23ab5f1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "ozon-tech-sorter-simulation",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "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"
+ },
+ "dependencies": {
+ "@vitejs/plugin-react": "latest",
+ "react": "latest",
+ "react-dom": "latest",
+ "typescript": "latest",
+ "vite": "latest"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.0",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3"
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..335aa4a
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useMemo, useState } from 'react';
+import Dashboard from './components/Dashboard';
+import { SCENARIOS } from './data/scenarios';
+import { createSimulation, setRunning, stepSimulation } from './domain/simulation';
+import type { ScenarioId, SimulationState } from './domain/types';
+
+const TICK_MS = 250;
+
+export default function App() {
+ const [activeScenarioId, setActiveScenarioId] = useState('normal_flow');
+ const activeScenario = useMemo(
+ () => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
+ [activeScenarioId],
+ );
+ const [simulation, setSimulation] = useState(() => createSimulation(activeScenario));
+
+ useEffect(() => {
+ setSimulation(createSimulation(activeScenario));
+ }, [activeScenario]);
+
+ useEffect(() => {
+ if (!simulation.running) {
+ return undefined;
+ }
+
+ const timer = window.setInterval(() => {
+ setSimulation((current) => stepSimulation(current, TICK_MS));
+ }, TICK_MS);
+
+ return () => window.clearInterval(timer);
+ }, [simulation.running]);
+
+ 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 handleScenarioChange = (scenarioId: ScenarioId) => setActiveScenarioId(scenarioId);
+
+ return (
+
+ );
+}
diff --git a/src/components/ClassificationPanel.tsx b/src/components/ClassificationPanel.tsx
new file mode 100644
index 0000000..2e5af88
--- /dev/null
+++ b/src/components/ClassificationPanel.tsx
@@ -0,0 +1,26 @@
+import { DIMENSION_LIMITS } from '../domain/classifier';
+import type { SimulatedItem } from '../domain/types';
+
+export default function ClassificationPanel({ currentItem }: { currentItem?: SimulatedItem }) {
+ const result = currentItem?.classification;
+
+ return (
+
+
+
Classification rules
+
Decision tree
+
+
+ -
+ dimensions check: {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} W, {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} D, {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} H
+
+ -
+ roundness check: threshold < {DIMENSION_LIMITS.roundnessThreshold}
+
+ -
+ result: {result ? `${result.category} - ${result.label}` : 'waiting for item'}
+
+
+
+ );
+}
diff --git a/src/components/CurrentItemPanel.tsx b/src/components/CurrentItemPanel.tsx
new file mode 100644
index 0000000..cf8285f
--- /dev/null
+++ b/src/components/CurrentItemPanel.tsx
@@ -0,0 +1,27 @@
+import type { SimulationState } from '../domain/types';
+
+export default function CurrentItemPanel({ simulation }: { simulation: SimulationState }) {
+ const current = simulation.currentItem;
+
+ return (
+
+
+
Current item
+
{current?.item.id ?? 'No active item'}
+
+ {current ? (
+
+ Name{current.item.name}
+ Dimensions{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm
+ Height{current.item.dimensionsMm.height} mm
+ Roundness{current.item.roundness.toFixed(2)}
+ Confidence{Math.round(current.item.confidence * 100)}%
+ Category{current.classification.category} / {current.classification.label}
+ Reason{current.classification.reason}
+
+ ) : (
+ Start or step the simulation to feed an item into zone A.
+ )}
+
+ );
+}
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
new file mode 100644
index 0000000..a00f463
--- /dev/null
+++ b/src/components/Dashboard.tsx
@@ -0,0 +1,66 @@
+import Header from './Header';
+import SorterScene from './SorterScene';
+import CurrentItemPanel from './CurrentItemPanel';
+import ClassificationPanel from './ClassificationPanel';
+import StateMachinePanel from './StateMachinePanel';
+import MetricsPanel from './MetricsPanel';
+import EventLog from './EventLog';
+import ScenarioPanel from './ScenarioPanel';
+import SensorPanel from './SensorPanel';
+import PidPanel from './PidPanel';
+import type { Scenario, ScenarioId, SimulationState } from '../domain/types';
+
+interface DashboardProps {
+ simulation: SimulationState;
+ scenarios: Scenario[];
+ activeScenarioId: ScenarioId;
+ onStart: () => void;
+ onPause: () => void;
+ onReset: () => void;
+ onStep: () => void;
+ onScenarioChange: (scenarioId: ScenarioId) => void;
+}
+
+export default function Dashboard({
+ simulation,
+ scenarios,
+ activeScenarioId,
+ onStart,
+ onPause,
+ onReset,
+ onStep,
+ onScenarioChange,
+}: DashboardProps) {
+ return (
+
+ );
+}
diff --git a/src/components/EventLog.tsx b/src/components/EventLog.tsx
new file mode 100644
index 0000000..17833ab
--- /dev/null
+++ b/src/components/EventLog.tsx
@@ -0,0 +1,33 @@
+import type { EventLogEntry } from '../domain/types';
+
+function formatTime(ms: number): string {
+ return `t+${(ms / 1000).toFixed(1)}s`;
+}
+
+export default function EventLog({ events }: { events: EventLogEntry[] }) {
+ return (
+
+
+
Event log
+
Cycle trace
+
+
+
+ timeitemtypemessagecategorystatus
+
+ {events.length === 0 ? (
+
No events yet. Press Start or Step.
+ ) : events.map((entry) => (
+
+ {formatTime(entry.timestampMs)}
+ {entry.itemId ?? '-'}
+ {entry.type}
+ {entry.message}
+ {entry.category ?? '-'}
+ {entry.status}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 0000000..6556e90
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,33 @@
+import type { SimulationState } from '../domain/types';
+
+interface HeaderProps {
+ simulation: SimulationState;
+ onStart: () => void;
+ onPause: () => void;
+ onReset: () => void;
+ onStep: () => void;
+}
+
+export default function Header({ simulation, onStart, onPause, onReset, onStep }: HeaderProps) {
+ return (
+
+ );
+}
diff --git a/src/components/MetricsPanel.tsx b/src/components/MetricsPanel.tsx
new file mode 100644
index 0000000..b696f4c
--- /dev/null
+++ b/src/components/MetricsPanel.tsx
@@ -0,0 +1,35 @@
+import type { Metrics } from '../domain/types';
+
+const metricLabels: Array<[keyof Metrics, string, string]> = [
+ ['processedCount', 'Processed', 'items'],
+ ['successCount', 'Success', 'items'],
+ ['errorCount', 'Errors', 'items'],
+ ['avgCycleTimeMs', 'Avg cycle', 'ms'],
+ ['throughputItemsPerMin', 'Throughput', 'items/min'],
+ ['cvLatencyMs', 'CV latency', 'ms'],
+ ['actuatorLatencyMs', 'Actuator latency', 'ms'],
+ ['queueLength', 'Queue length', 'items'],
+ ['conveyorSpeedMps', 'Conveyor speed', 'm/s'],
+ ['pidTargetSpeedMps', 'PID target', 'm/s'],
+ ['pidActualSpeedMps', 'PID actual', 'm/s'],
+];
+
+export default function MetricsPanel({ metrics }: { metrics: Metrics }) {
+ return (
+
+
+
Metrics
+
Line telemetry
+
+
+ {metricLabels.map(([key, label, unit]) => (
+
+ {label}
+ {metrics[key]}
+ {unit}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/PidPanel.tsx b/src/components/PidPanel.tsx
new file mode 100644
index 0000000..dd8a8c4
--- /dev/null
+++ b/src/components/PidPanel.tsx
@@ -0,0 +1,42 @@
+import type { Metrics, PidState, SystemStatus } from '../domain/types';
+
+interface PidPanelProps {
+ pid: PidState;
+ metrics: Metrics;
+ status: SystemStatus;
+}
+
+export default function PidPanel({ pid, status }: PidPanelProps) {
+ const fill = Math.min((pid.actualSpeedMps / Math.max(pid.targetSpeedMps, 0.01)) * 100, 100);
+
+ return (
+
+
+
Conveyor control loop
+
PID imitation
+
+
+
+ targetSpeedMps
+ {pid.targetSpeedMps.toFixed(2)}
+
+
+ actualSpeedMps
+ {pid.actualSpeedMps.toFixed(2)}
+
+
+ pidError
+ {pid.pidError.toFixed(2)}
+
+
+ correction
+ {pid.correction.toFixed(2)}
+
+
+
+ Status {status}: actual speed follows target during flow and decays to zero on jam/emergency.
+
+ );
+}
diff --git a/src/components/ScenarioPanel.tsx b/src/components/ScenarioPanel.tsx
new file mode 100644
index 0000000..2327ea1
--- /dev/null
+++ b/src/components/ScenarioPanel.tsx
@@ -0,0 +1,30 @@
+import type { Scenario, ScenarioId } from '../domain/types';
+
+interface ScenarioPanelProps {
+ scenarios: Scenario[];
+ activeScenarioId: ScenarioId;
+ onScenarioChange: (scenarioId: ScenarioId) => void;
+}
+
+export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioChange }: ScenarioPanelProps) {
+ return (
+
+
+
Scenario panel
+
Test cases
+
+
+ {scenarios.map((scenario) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/SensorPanel.tsx b/src/components/SensorPanel.tsx
new file mode 100644
index 0000000..d7d80fb
--- /dev/null
+++ b/src/components/SensorPanel.tsx
@@ -0,0 +1,37 @@
+import type { SensorState, SimulationState } from '../domain/types';
+
+function SensorRow({ sensor }: { sensor: SensorState }) {
+ return (
+
+
+ {sensor.label}
+ {sensor.lastValue}
+
+
+ {sensor.latencyMs} ms
+ t+{(sensor.lastEventTimestampMs / 1000).toFixed(1)}s
+
+
+ );
+}
+
+export default function SensorPanel({ simulation }: { simulation: SimulationState }) {
+ return (
+
+
+
Sensors and actuators
+
Live I/O
+
+
+
+
+
+
+
+
Stop-gate{simulation.gate.open ? 'open' : 'closed'}
+
Pusher C{simulation.actuators.pusherC}
+
Pusher D{simulation.actuators.pusherD}
+
+
+ );
+}
diff --git a/src/components/SorterScene.tsx b/src/components/SorterScene.tsx
new file mode 100644
index 0000000..23b3e4a
--- /dev/null
+++ b/src/components/SorterScene.tsx
@@ -0,0 +1,163 @@
+import type { Category, MachineState, SimulationState } from '../domain/types';
+
+const routeColors: Record = {
+ B: '#4ade80',
+ C: '#f59e0b',
+ D: '#c084fc',
+};
+
+function progressForState(state: MachineState, elapsedMs: number): number {
+ const ratios: Partial> = {
+ MOVING_TO_CAMERA: [0.08, 0.34, 1200],
+ DETECTING: [0.34, 0.36, 900],
+ MOVING_TO_GATE: [0.36, 0.64, 1300],
+ WAITING_AT_GATE: [0.64, 0.65, 800],
+ CLASSIFYING: [0.65, 0.66, 700],
+ ROUTE_TO_B: [0.66, 0.92, 1200],
+ ROUTE_TO_C: [0.66, 0.82, 1200],
+ ROUTE_TO_D: [0.66, 0.82, 1200],
+ RETURN_HOME: [0.92, 0.94, 700],
+ };
+ const segment = ratios[state];
+ if (!segment) {
+ return state === 'IDLE' ? 0 : 0.65;
+ }
+ const [from, to, duration] = segment;
+ return from + (to - from) * Math.min(elapsedMs / duration, 1);
+}
+
+function itemPosition(simulation: SimulationState): { x: number; y: number } {
+ const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs);
+ const baseX = 120 + progress * 760;
+ const beltY = 315;
+
+ if (simulation.machineState === 'ROUTE_TO_C') {
+ return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
+ }
+
+ if (simulation.machineState === 'ROUTE_TO_D') {
+ return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
+ }
+
+ return { x: baseX, y: beltY };
+}
+
+export default function SorterScene({ simulation }: { simulation: SimulationState }) {
+ 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 routeVisible = simulation.machineState.startsWith('ROUTE_TO_');
+
+ return (
+
+
+
+
Work zone 6000 x 10000 mm
+
Sorting line digital twin
+
+
{simulation.machineState}
+
+
+
+ );
+}
diff --git a/src/components/StateMachinePanel.tsx b/src/components/StateMachinePanel.tsx
new file mode 100644
index 0000000..06dc3da
--- /dev/null
+++ b/src/components/StateMachinePanel.tsx
@@ -0,0 +1,35 @@
+import type { MachineState } from '../domain/types';
+
+const states: MachineState[] = [
+ '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',
+];
+
+export default function StateMachinePanel({ currentState }: { currentState: MachineState }) {
+ return (
+
+
+
State machine
+
{currentState}
+
+
+ {states.map((state) => (
+
+
+ {state}
+
+ ))}
+
+
+ );
+}
diff --git a/src/data/items.ts b/src/data/items.ts
new file mode 100644
index 0000000..39c9214
--- /dev/null
+++ b/src/data/items.ts
@@ -0,0 +1,102 @@
+import type { Item } from '../domain/types';
+
+export const ITEMS: Item[] = [
+ {
+ id: 'SKU-001',
+ name: 'Box 300x200x200',
+ dimensionsMm: { width: 300, depth: 200, height: 200 },
+ roundness: 0.12,
+ confidence: 0.94,
+ shape: 'box',
+ expectedCategory: 'B',
+ },
+ {
+ id: 'SKU-002',
+ name: 'Lunchbox 201x152x62',
+ dimensionsMm: { width: 201, depth: 152, height: 62 },
+ roundness: 0.22,
+ confidence: 0.91,
+ shape: 'rectangular prism',
+ expectedCategory: 'B',
+ },
+ {
+ id: 'SKU-003',
+ name: 'Detergent 259x179x278',
+ dimensionsMm: { width: 259, depth: 179, height: 278 },
+ roundness: 0.38,
+ confidence: 0.88,
+ shape: 'bottle box',
+ expectedCategory: 'B',
+ },
+ {
+ id: 'SKU-004',
+ name: 'Oversized box 401x300x400',
+ dimensionsMm: { width: 401, depth: 300, height: 400 },
+ roundness: 0.18,
+ confidence: 0.9,
+ shape: 'oversized box',
+ expectedCategory: 'C',
+ },
+ {
+ id: 'SKU-005',
+ name: 'Pouf 489x264x489',
+ dimensionsMm: { width: 489, depth: 264, height: 489 },
+ roundness: 0.74,
+ confidence: 0.86,
+ shape: 'soft bulky item',
+ expectedCategory: 'C',
+ },
+ {
+ id: 'SKU-006',
+ name: 'Plate 210x209x27',
+ dimensionsMm: { width: 210, depth: 209, height: 27 },
+ roundness: 0.95,
+ confidence: 0.89,
+ shape: 'round plate',
+ expectedCategory: 'D',
+ },
+ {
+ id: 'SKU-007',
+ name: 'Bottle 91x91x305',
+ dimensionsMm: { width: 91, depth: 91, height: 305 },
+ roundness: 0.92,
+ confidence: 0.93,
+ shape: 'cylinder bottle',
+ expectedCategory: 'D',
+ },
+ {
+ id: 'SKU-008',
+ name: 'Cylinder 435x50x43',
+ dimensionsMm: { width: 435, depth: 50, height: 43 },
+ roundness: 0.88,
+ confidence: 0.87,
+ shape: 'long cylinder',
+ expectedCategory: 'D',
+ },
+ {
+ id: 'SKU-009',
+ name: 'Pen 9x13x148',
+ dimensionsMm: { width: 9, depth: 13, height: 148 },
+ roundness: 0.66,
+ confidence: 0.84,
+ shape: 'thin item',
+ expectedCategory: 'C',
+ },
+ {
+ id: 'SKU-010',
+ name: 'Boundary box 450x320x320',
+ dimensionsMm: { width: 450, depth: 320, height: 320 },
+ roundness: 0.2,
+ confidence: 0.9,
+ shape: 'boundary box',
+ expectedCategory: 'B',
+ },
+];
+
+export function getItem(id: string): Item {
+ const item = ITEMS.find((candidate) => candidate.id === id);
+ if (!item) {
+ throw new Error(`Unknown item id: ${id}`);
+ }
+ return item;
+}
diff --git a/src/data/scenarios.ts b/src/data/scenarios.ts
new file mode 100644
index 0000000..480e3b5
--- /dev/null
+++ b/src/data/scenarios.ts
@@ -0,0 +1,60 @@
+import { getItem } from './items';
+import type { Scenario } from '../domain/types';
+
+export const SCENARIOS: Scenario[] = [
+ {
+ id: 'normal_flow',
+ name: 'Normal flow',
+ description: 'Обычный поток из нескольких товаров B/C/D с полным циклом сортировки.',
+ items: ['SKU-001', 'SKU-006', 'SKU-004', 'SKU-003', 'SKU-007'].map(getItem),
+ },
+ {
+ id: 'oversized_item',
+ name: 'Oversized item',
+ description: 'Товар выходит за max dimensions и должен попасть в roll-cage C.',
+ items: ['SKU-004', 'SKU-005'].map(getItem),
+ },
+ {
+ id: 'round_object',
+ name: 'Round object',
+ description: 'Габариты проходят, но roundness >= 0.8, маршрут в D.',
+ items: ['SKU-006', 'SKU-007', 'SKU-008'].map(getItem),
+ },
+ {
+ id: 'boundary_dimensions',
+ name: 'Boundary dimensions',
+ description: 'Товары около min/max границ показывают устойчивость правил.',
+ items: ['SKU-010', 'SKU-009', 'SKU-002'].map(getItem),
+ },
+ {
+ id: 'close_items',
+ name: 'Close items',
+ description: 'Два товара близко друг к другу: warning queue/spacing и последовательная обработка.',
+ items: ['SKU-001', 'SKU-002', 'SKU-006'].map(getItem),
+ },
+ {
+ id: 'low_confidence',
+ name: 'Low confidence',
+ description: 'CV confidence ниже 0.65, система предупреждает и принимает rule-based решение.',
+ items: [
+ { ...getItem('SKU-003'), id: 'SKU-003-LC', confidence: 0.58 },
+ { ...getItem('SKU-006'), id: 'SKU-006-LC', confidence: 0.61 },
+ ],
+ },
+ {
+ id: 'jam',
+ name: 'Jam at gate',
+ description: 'Застревание у stop-gate переводит систему в FAULT и останавливает конвейер.',
+ items: ['SKU-004'].map(getItem),
+ },
+ {
+ id: 'emergency_stop',
+ name: 'Emergency stop',
+ description: 'Аварийная остановка переводит систему в EMERGENCY_STOP, движение остановлено.',
+ items: ['SKU-001', 'SKU-006'].map(getItem),
+ },
+];
+
+export function getScenario(id: string): Scenario {
+ return SCENARIOS.find((scenario) => scenario.id === id) ?? SCENARIOS[0];
+}
diff --git a/src/domain/classifier.ts b/src/domain/classifier.ts
new file mode 100644
index 0000000..6bd0dee
--- /dev/null
+++ b/src/domain/classifier.ts
@@ -0,0 +1,52 @@
+import type { Category, CategoryDefinition, ClassificationResult, Item } from './types';
+
+export const CATEGORY_DEFINITIONS: Record = {
+ B: {
+ label: 'Основной сортировщик',
+ reason: 'Габариты подходят, круговое сечение не обнаружено',
+ },
+ C: {
+ label: 'Неправильные габариты',
+ reason: 'Нарушены допустимые габариты',
+ },
+ D: {
+ label: 'Неправильная форма / доупаковка',
+ reason: 'Обнаружен признак круглого сечения',
+ },
+};
+
+export const DIMENSION_LIMITS = {
+ min: { width: 10, depth: 10, height: 2 },
+ max: { width: 450, depth: 320, height: 320 },
+ roundnessThreshold: 0.8,
+};
+
+export function classifyItem(item: Item): ClassificationResult {
+ const { width, depth, height } = item.dimensionsMm;
+ const dimensionsPass =
+ width >= DIMENSION_LIMITS.min.width &&
+ depth >= DIMENSION_LIMITS.min.depth &&
+ height >= DIMENSION_LIMITS.min.height &&
+ width <= DIMENSION_LIMITS.max.width &&
+ depth <= DIMENSION_LIMITS.max.depth &&
+ height <= DIMENSION_LIMITS.max.height;
+
+ const roundnessPass = item.roundness < DIMENSION_LIMITS.roundnessThreshold;
+ const warnings: string[] = [];
+
+ if (item.confidence < 0.65) {
+ warnings.push('CV confidence ниже 0.65, решение принято rule-based способом');
+ }
+
+ const category: Category = !dimensionsPass ? 'C' : roundnessPass ? 'B' : 'D';
+ const definition = CATEGORY_DEFINITIONS[category];
+
+ return {
+ category,
+ label: definition.label,
+ reason: definition.reason,
+ dimensionsPass,
+ roundnessPass,
+ warnings,
+ };
+}
diff --git a/src/domain/metrics.ts b/src/domain/metrics.ts
new file mode 100644
index 0000000..2b17afc
--- /dev/null
+++ b/src/domain/metrics.ts
@@ -0,0 +1,31 @@
+import type { Metrics } from './types';
+
+export function createMetrics(): Metrics {
+ return {
+ processedCount: 0,
+ successCount: 0,
+ errorCount: 0,
+ avgCycleTimeMs: 0,
+ throughputItemsPerMin: 0,
+ cvLatencyMs: 0,
+ actuatorLatencyMs: 0,
+ queueLength: 0,
+ conveyorSpeedMps: 0,
+ pidTargetSpeedMps: 0.42,
+ pidActualSpeedMps: 0,
+ };
+}
+
+export function recordCycle(metrics: Metrics, cycleTimeMs: number, success: boolean): Metrics {
+ const processedCount = metrics.processedCount + 1;
+ const totalCycleTime = metrics.avgCycleTimeMs * metrics.processedCount + cycleTimeMs;
+
+ return {
+ ...metrics,
+ processedCount,
+ successCount: metrics.successCount + (success ? 1 : 0),
+ errorCount: metrics.errorCount + (success ? 0 : 1),
+ avgCycleTimeMs: Math.round(totalCycleTime / processedCount),
+ throughputItemsPerMin: Number((60000 / Math.max(cycleTimeMs, 1)).toFixed(1)),
+ };
+}
diff --git a/src/domain/pid.ts b/src/domain/pid.ts
new file mode 100644
index 0000000..35dd1bc
--- /dev/null
+++ b/src/domain/pid.ts
@@ -0,0 +1,24 @@
+import type { PidState } from './types';
+
+export function createPidState(targetSpeedMps = 0.42): PidState {
+ return {
+ targetSpeedMps,
+ actualSpeedMps: 0,
+ pidError: targetSpeedMps,
+ correction: 0,
+ };
+}
+
+export function updatePid(state: PidState, targetSpeedMps: number, deltaMs: number): PidState {
+ const dt = Math.max(deltaMs / 1000, 0.1);
+ const pidError = targetSpeedMps - state.actualSpeedMps;
+ const correction = pidError * 0.42;
+ const nextActual = Math.max(0, state.actualSpeedMps + correction * dt);
+
+ return {
+ targetSpeedMps,
+ actualSpeedMps: Number(nextActual.toFixed(3)),
+ pidError: Number(pidError.toFixed(3)),
+ correction: Number(correction.toFixed(3)),
+ };
+}
diff --git a/src/domain/simulation.ts b/src/domain/simulation.ts
new file mode 100644
index 0000000..4707db9
--- /dev/null
+++ b/src/domain/simulation.ts
@@ -0,0 +1,408 @@
+import { classifyItem } from './classifier';
+import { createMetrics, recordCycle } from './metrics';
+import { createPidState, updatePid } from './pid';
+import type {
+ Category,
+ EventLogEntry,
+ MachineState,
+ Scenario,
+ SimulationState,
+ SystemStatus,
+} from './types';
+
+const STATE_DURATIONS_MS: Partial> = {
+ MOVING_TO_CAMERA: 1200,
+ DETECTING: 900,
+ MOVING_TO_GATE: 1300,
+ WAITING_AT_GATE: 800,
+ CLASSIFYING: 700,
+ ROUTE_TO_B: 1200,
+ ROUTE_TO_C: 1200,
+ ROUTE_TO_D: 1200,
+ RETURN_HOME: 700,
+};
+
+const ROUTE_STATE_BY_CATEGORY: Record = {
+ B: 'ROUTE_TO_B',
+ C: 'ROUTE_TO_C',
+ D: 'ROUTE_TO_D',
+};
+
+let eventCounter = 0;
+
+function event(timestampMs: number, entry: Omit): EventLogEntry {
+ eventCounter += 1;
+ return {
+ id: `evt-${eventCounter}`,
+ timestampMs,
+ ...entry,
+ };
+}
+
+function appendEvent(state: SimulationState, entry: Omit): SimulationState {
+ return {
+ ...state,
+ events: [event(state.simTimeMs, entry), ...state.events].slice(0, 80),
+ };
+}
+
+function transitionTo(state: SimulationState, nextState: MachineState): SimulationState {
+ const item = state.currentItem?.item;
+ let next: SimulationState = {
+ ...state,
+ machineState: nextState,
+ elapsedInStateMs: 0,
+ activeRoute: nextState.startsWith('ROUTE_TO_') ? state.currentItem?.classification.category : undefined,
+ };
+
+ if (!item) {
+ return next;
+ }
+
+ if (nextState === 'DETECTING') {
+ next = appendEvent(next, {
+ itemId: item.id,
+ type: 'sensor',
+ message: `Camera captured bbox, confidence ${Math.round(item.confidence * 100)}%`,
+ category: state.currentItem?.classification.category,
+ status: item.confidence < 0.65 ? 'warning' : 'info',
+ });
+ }
+
+ if (nextState === 'WAITING_AT_GATE') {
+ next = appendEvent(next, {
+ itemId: item.id,
+ type: 'actuator',
+ message: 'Stop-gate closed, item fixed before classification',
+ status: 'info',
+ });
+ }
+
+ if (nextState === 'CLASSIFYING' && state.currentItem) {
+ next = appendEvent(next, {
+ itemId: item.id,
+ type: 'classification',
+ message: `${state.currentItem.classification.label}: ${state.currentItem.classification.reason}`,
+ category: state.currentItem.classification.category,
+ status: state.currentItem.classification.warnings.length > 0 ? 'warning' : 'success',
+ });
+ }
+
+ if (nextState.startsWith('ROUTE_TO_') && state.currentItem) {
+ next = appendEvent(next, {
+ itemId: item.id,
+ type: 'routing',
+ message: `Route selected: ${state.currentItem.classification.category}`,
+ category: state.currentItem.classification.category,
+ status: 'success',
+ });
+ }
+
+ return next;
+}
+
+function startNextItem(state: SimulationState): SimulationState {
+ const item = state.scenario.items[state.itemIndex];
+ if (!item) {
+ return { ...state, machineState: 'IDLE', running: false, systemStatus: 'PAUSED' };
+ }
+
+ const classification = classifyItem(item);
+ let next: SimulationState = {
+ ...state,
+ currentItem: {
+ item,
+ classification,
+ startedAtMs: state.simTimeMs,
+ cycleTimeMs: 0,
+ },
+ machineState: 'MOVING_TO_CAMERA',
+ elapsedInStateMs: 0,
+ itemIndex: state.itemIndex + 1,
+ };
+
+ next = appendEvent(next, {
+ itemId: item.id,
+ type: 'system',
+ message: `Item entered zone A: ${item.name}`,
+ status: 'info',
+ });
+
+ if (state.scenario.id === 'close_items' && state.itemIndex === 0) {
+ next = appendEvent(next, {
+ itemId: item.id,
+ type: 'warning',
+ message: 'Queue/spacing warning: next item detected too close, processing sequentially',
+ status: 'warning',
+ });
+ }
+
+ for (const warning of classification.warnings) {
+ next = appendEvent(next, {
+ itemId: item.id,
+ type: 'warning',
+ message: warning,
+ category: classification.category,
+ status: 'warning',
+ });
+ }
+
+ return next;
+}
+
+function completeCurrentItem(state: SimulationState): SimulationState {
+ if (!state.currentItem) {
+ return state;
+ }
+
+ const cycleTimeMs = state.simTimeMs - state.currentItem.startedAtMs;
+ const success = state.currentItem.classification.category === state.currentItem.item.expectedCategory;
+ const metrics = recordCycle(state.metrics, cycleTimeMs, success);
+
+ let next = appendEvent(
+ {
+ ...state,
+ metrics,
+ },
+ {
+ itemId: state.currentItem.item.id,
+ type: 'routing',
+ message: `Cycle completed in ${(cycleTimeMs / 1000).toFixed(1)}s`,
+ category: state.currentItem.classification.category,
+ status: success ? 'success' : 'error',
+ },
+ );
+
+ next = {
+ ...next,
+ currentItem: undefined,
+ activeRoute: undefined,
+ };
+
+ return transitionTo(next, 'RETURN_HOME');
+}
+
+function targetSpeedFor(state: SimulationState): number {
+ if (state.machineState === 'FAULT' || state.machineState === 'EMERGENCY_STOP') {
+ return 0;
+ }
+ if (!state.running) {
+ return 0;
+ }
+ return state.scenario.id === 'close_items' ? 0.32 : 0.42;
+}
+
+function deriveSensorsAndActuators(state: SimulationState): SimulationState {
+ const item = state.currentItem?.item;
+ const classification = state.currentItem?.classification;
+ const cameraActive = state.machineState === 'DETECTING';
+ const laserActive = state.machineState === 'DETECTING' || state.machineState === 'MOVING_TO_GATE';
+ const ultrasoundActive = state.machineState === 'WAITING_AT_GATE' || state.machineState === 'CLASSIFYING';
+ const route = classification?.category;
+ const routeToC = state.machineState === 'ROUTE_TO_C';
+ const routeToD = state.machineState === 'ROUTE_TO_D';
+
+ return {
+ ...state,
+ gate: {
+ open: state.machineState === 'ROUTE_TO_B' || state.machineState === 'IDLE',
+ },
+ actuators: {
+ pusherC: routeToC ? 'extended' : state.machineState === 'RETURN_HOME' && route === 'C' ? 'retracting' : 'idle',
+ pusherD: routeToD ? 'extended' : state.machineState === 'RETURN_HOME' && route === 'D' ? 'retracting' : 'idle',
+ },
+ sensors: {
+ camera: {
+ kind: 'camera',
+ label: 'Camera / pseudo-CV',
+ active: cameraActive,
+ lastValue: item ? `bbox ${item.dimensionsMm.width}x${item.dimensionsMm.depth} mm` : 'idle',
+ latencyMs: item ? Math.round(38 + (1 - item.confidence) * 80) : 0,
+ lastEventTimestampMs: cameraActive ? state.simTimeMs : state.sensors.camera.lastEventTimestampMs,
+ bbox: item ? { x: 0, y: 0, width: item.dimensionsMm.width, height: item.dimensionsMm.depth } : undefined,
+ confidence: item?.confidence,
+ cvLatencyMs: item ? Math.round(38 + (1 - item.confidence) * 80) : 0,
+ },
+ laser: {
+ kind: 'laser',
+ label: 'Laser height sensor',
+ active: laserActive,
+ lastValue: item ? `${item.dimensionsMm.height} mm` : 'idle',
+ latencyMs: item ? 14 : 0,
+ lastEventTimestampMs: laserActive ? state.simTimeMs : state.sensors.laser.lastEventTimestampMs,
+ measuredHeightMm: item?.dimensionsMm.height,
+ },
+ ultrasound: {
+ kind: 'ultrasound',
+ label: 'Ultrasound gate sensor',
+ active: ultrasoundActive,
+ lastValue: ultrasoundActive ? 'object at gate' : item ? 'tracking' : 'idle',
+ latencyMs: item ? 22 : 0,
+ lastEventTimestampMs: ultrasoundActive ? state.simTimeMs : state.sensors.ultrasound.lastEventTimestampMs,
+ distanceToGateMm: ultrasoundActive ? 0 : item ? 900 : undefined,
+ objectAtGate: ultrasoundActive,
+ },
+ },
+ };
+}
+
+export function createSimulation(scenario: Scenario): SimulationState {
+ const pid = createPidState();
+ return deriveSensorsAndActuators({
+ scenario,
+ systemStatus: 'PAUSED',
+ running: false,
+ machineState: 'IDLE',
+ itemIndex: 0,
+ elapsedInStateMs: 0,
+ simTimeMs: 0,
+ currentItem: undefined,
+ metrics: createMetrics(),
+ sensors: {
+ camera: {
+ kind: 'camera',
+ label: 'Camera / pseudo-CV',
+ active: false,
+ lastValue: 'idle',
+ latencyMs: 0,
+ lastEventTimestampMs: 0,
+ cvLatencyMs: 0,
+ },
+ laser: {
+ kind: 'laser',
+ label: 'Laser height sensor',
+ active: false,
+ lastValue: 'idle',
+ latencyMs: 0,
+ lastEventTimestampMs: 0,
+ },
+ ultrasound: {
+ kind: 'ultrasound',
+ label: 'Ultrasound gate sensor',
+ active: false,
+ lastValue: 'idle',
+ latencyMs: 0,
+ lastEventTimestampMs: 0,
+ objectAtGate: false,
+ },
+ },
+ gate: { open: true },
+ actuators: { pusherC: 'idle', pusherD: 'idle' },
+ pid,
+ events: [],
+ });
+}
+
+export function setRunning(state: SimulationState, running: boolean): SimulationState {
+ const systemStatus: SystemStatus = state.machineState === 'FAULT' ? 'FAULT' : state.machineState === 'EMERGENCY_STOP' ? 'EMERGENCY_STOP' : running ? 'RUNNING' : 'PAUSED';
+ return deriveSensorsAndActuators({ ...state, running, systemStatus });
+}
+
+export function stepSimulation(state: SimulationState, deltaMs: number, forceStep = false): SimulationState {
+ if (!state.running && !forceStep) {
+ return state;
+ }
+
+ let next: SimulationState = {
+ ...state,
+ simTimeMs: state.simTimeMs + deltaMs,
+ elapsedInStateMs: state.elapsedInStateMs + deltaMs,
+ };
+
+ const shouldEmergencyStop = next.scenario.id === 'emergency_stop' && next.simTimeMs >= 1800;
+ if (shouldEmergencyStop && next.machineState !== 'EMERGENCY_STOP') {
+ next = appendEvent(next, {
+ itemId: next.currentItem?.item.id,
+ type: 'fault',
+ message: 'Emergency stop pressed, all movement stopped',
+ status: 'error',
+ });
+ next = { ...next, machineState: 'EMERGENCY_STOP', running: false, systemStatus: 'EMERGENCY_STOP' };
+ }
+
+ const shouldJam = next.scenario.id === 'jam' && next.machineState === 'WAITING_AT_GATE' && next.elapsedInStateMs >= 700;
+ if (shouldJam) {
+ next = appendEvent(next, {
+ itemId: next.currentItem?.item.id,
+ type: 'fault',
+ message: 'Jam detected at stop-gate, conveyor stopped',
+ category: next.currentItem?.classification.category,
+ status: 'error',
+ });
+ next = { ...next, machineState: 'FAULT', running: false, systemStatus: 'FAULT' };
+ }
+
+ if (next.machineState === 'FAULT' || next.machineState === 'EMERGENCY_STOP') {
+ const pid = updatePid(next.pid, 0, deltaMs);
+ return deriveSensorsAndActuators({
+ ...next,
+ pid,
+ metrics: {
+ ...next.metrics,
+ conveyorSpeedMps: pid.actualSpeedMps,
+ pidTargetSpeedMps: pid.targetSpeedMps,
+ pidActualSpeedMps: pid.actualSpeedMps,
+ },
+ });
+ }
+
+ if (next.machineState === 'IDLE') {
+ next = startNextItem(next);
+ }
+
+ const duration = STATE_DURATIONS_MS[next.machineState];
+ if (duration && next.elapsedInStateMs >= duration) {
+ switch (next.machineState) {
+ case 'MOVING_TO_CAMERA':
+ next = transitionTo(next, 'DETECTING');
+ break;
+ case 'DETECTING':
+ next = transitionTo(next, 'MOVING_TO_GATE');
+ break;
+ case 'MOVING_TO_GATE':
+ next = transitionTo(next, 'WAITING_AT_GATE');
+ break;
+ case 'WAITING_AT_GATE':
+ next = transitionTo(next, 'CLASSIFYING');
+ break;
+ case 'CLASSIFYING':
+ if (next.currentItem) {
+ next = transitionTo(next, ROUTE_STATE_BY_CATEGORY[next.currentItem.classification.category]);
+ }
+ break;
+ case 'ROUTE_TO_B':
+ case 'ROUTE_TO_C':
+ case 'ROUTE_TO_D':
+ next = completeCurrentItem(next);
+ break;
+ case 'RETURN_HOME':
+ next = next.itemIndex < next.scenario.items.length ? startNextItem(next) : { ...next, machineState: 'IDLE', running: false, systemStatus: 'PAUSED', elapsedInStateMs: 0 };
+ break;
+ default:
+ break;
+ }
+ }
+
+ const targetSpeed = targetSpeedFor(next);
+ const pid = updatePid(next.pid, targetSpeed, deltaMs);
+ const queueLength = Math.max(next.scenario.items.length - next.itemIndex, 0) + (next.currentItem ? 1 : 0);
+ const cvLatencyMs = next.sensors.camera.cvLatencyMs || next.metrics.cvLatencyMs;
+ const actuatorLatencyMs = next.actuators.pusherC !== 'idle' || next.actuators.pusherD !== 'idle' ? 115 : next.machineState === 'ROUTE_TO_B' ? 55 : 0;
+
+ next = {
+ ...next,
+ pid,
+ systemStatus: next.running ? 'RUNNING' : next.systemStatus,
+ metrics: {
+ ...next.metrics,
+ cvLatencyMs,
+ actuatorLatencyMs,
+ queueLength,
+ conveyorSpeedMps: pid.actualSpeedMps,
+ pidTargetSpeedMps: pid.targetSpeedMps,
+ pidActualSpeedMps: pid.actualSpeedMps,
+ },
+ };
+
+ return deriveSensorsAndActuators(next);
+}
diff --git a/src/domain/types.ts b/src/domain/types.ts
new file mode 100644
index 0000000..87ff7d5
--- /dev/null
+++ b/src/domain/types.ts
@@ -0,0 +1,173 @@
+export type Category = 'B' | 'C' | 'D';
+
+export type MachineState =
+ | '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';
+
+export type SystemStatus = 'RUNNING' | 'PAUSED' | 'FAULT' | 'EMERGENCY_STOP';
+
+export type ScenarioId =
+ | 'normal_flow'
+ | 'oversized_item'
+ | 'round_object'
+ | 'boundary_dimensions'
+ | 'close_items'
+ | 'low_confidence'
+ | 'jam'
+ | 'emergency_stop';
+
+export type SensorKind = 'camera' | 'laser' | 'ultrasound';
+export type EventType = 'system' | 'sensor' | 'classification' | 'actuator' | 'routing' | 'warning' | 'fault';
+export type EventStatus = 'info' | 'success' | 'warning' | 'error';
+export type PusherState = 'idle' | 'extended' | 'retracting';
+
+export interface DimensionsMm {
+ width: number;
+ depth: number;
+ height: number;
+}
+
+export interface Item {
+ id: string;
+ name: string;
+ dimensionsMm: DimensionsMm;
+ roundness: number;
+ confidence: number;
+ shape: string;
+ expectedCategory: Category;
+}
+
+export interface CategoryDefinition {
+ label: string;
+ reason: string;
+}
+
+export interface ClassificationResult {
+ category: Category;
+ label: string;
+ reason: string;
+ dimensionsPass: boolean;
+ roundnessPass: boolean;
+ warnings: string[];
+}
+
+export interface Scenario {
+ id: ScenarioId;
+ name: string;
+ description: string;
+ items: Item[];
+ initialStatus?: SystemStatus;
+}
+
+export interface EventLogEntry {
+ id: string;
+ timestampMs: number;
+ itemId?: string;
+ type: EventType;
+ message: string;
+ category?: Category;
+ status: EventStatus;
+}
+
+export interface Metrics {
+ processedCount: number;
+ successCount: number;
+ errorCount: number;
+ avgCycleTimeMs: number;
+ throughputItemsPerMin: number;
+ cvLatencyMs: number;
+ actuatorLatencyMs: number;
+ queueLength: number;
+ conveyorSpeedMps: number;
+ pidTargetSpeedMps: number;
+ pidActualSpeedMps: number;
+}
+
+export interface SensorState {
+ kind: SensorKind;
+ label: string;
+ active: boolean;
+ lastValue: string;
+ latencyMs: number;
+ lastEventTimestampMs: number;
+}
+
+export interface CameraState extends SensorState {
+ kind: 'camera';
+ bbox?: BoundingBox;
+ confidence?: number;
+ cvLatencyMs: number;
+}
+
+export interface LaserState extends SensorState {
+ kind: 'laser';
+ measuredHeightMm?: number;
+}
+
+export interface UltrasoundState extends SensorState {
+ kind: 'ultrasound';
+ distanceToGateMm?: number;
+ objectAtGate: boolean;
+}
+
+export interface BoundingBox {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface StopGateState {
+ open: boolean;
+}
+
+export interface ActuatorState {
+ pusherC: PusherState;
+ pusherD: PusherState;
+}
+
+export interface PidState {
+ targetSpeedMps: number;
+ actualSpeedMps: number;
+ pidError: number;
+ correction: number;
+}
+
+export interface SimulatedItem {
+ item: Item;
+ startedAtMs: number;
+ classification: ClassificationResult;
+ cycleTimeMs: number;
+}
+
+export interface SimulationState {
+ scenario: Scenario;
+ systemStatus: SystemStatus;
+ running: boolean;
+ machineState: MachineState;
+ itemIndex: number;
+ elapsedInStateMs: number;
+ simTimeMs: number;
+ currentItem?: SimulatedItem;
+ metrics: Metrics;
+ sensors: {
+ camera: CameraState;
+ laser: LaserState;
+ ultrasound: UltrasoundState;
+ };
+ gate: StopGateState;
+ actuators: ActuatorState;
+ pid: PidState;
+ events: EventLogEntry[];
+ activeRoute?: Category;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..97b89ca
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+
+
+ ,
+);
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..a2c230c
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,520 @@
+:root {
+ color-scheme: dark;
+ --bg: #050910;
+ --panel: rgba(10, 19, 31, 0.92);
+ --panel-strong: #0c1725;
+ --line: #1e3a54;
+ --muted: #91a4b8;
+ --text: #e5f2ff;
+ --cyan: #38bdf8;
+ --green: #4ade80;
+ --orange: #f59e0b;
+ --purple: #c084fc;
+ --red: #fb3d4e;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 1180px;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 20% 10%, rgba(56, 189, 248, 0.13), transparent 26%),
+ radial-gradient(circle at 86% 30%, rgba(192, 132, 252, 0.12), transparent 26%),
+ linear-gradient(135deg, #03060b 0%, #07111d 54%, #050910 100%);
+ color: var(--text);
+}
+
+button {
+ border: 1px solid #2a4a66;
+ border-radius: 10px;
+ background: #0e1b2b;
+ color: var(--text);
+ cursor: pointer;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
+}
+
+button:hover,
+button:focus-visible {
+ border-color: var(--cyan);
+ background: #11273b;
+ outline: none;
+ transform: translateY(-1px);
+}
+
+.app-shell {
+ min-height: 100vh;
+ padding: 18px;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 16px 18px;
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ background: rgba(7, 14, 23, 0.9);
+ box-shadow: 0 18px 80px rgba(0, 0, 0, 0.35);
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: clamp(28px, 3vw, 42px);
+ letter-spacing: -0.045em;
+}
+
+h2 {
+ font-size: 18px;
+ letter-spacing: -0.02em;
+}
+
+.eyebrow {
+ color: var(--cyan);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.header-status {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.status-pill,
+.machine-state-chip,
+.scenario-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 38px;
+ padding: 8px 12px;
+ border: 1px solid #284762;
+ border-radius: 999px;
+ background: #091522;
+ color: #c8d7e8;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.status-pill span {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--green);
+ box-shadow: 0 0 18px var(--green);
+}
+
+.status-paused span { background: var(--orange); box-shadow: 0 0 18px var(--orange); }
+.status-fault span,
+.status-emergency_stop span { background: var(--red); box-shadow: 0 0 18px var(--red); }
+
+.header-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.header-actions button {
+ min-height: 38px;
+ padding: 0 14px;
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(760px, 1fr) 390px;
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.panel {
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: var(--panel);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 18px 60px rgba(0, 0, 0, 0.2);
+}
+
+.panel-scene {
+ min-height: 672px;
+ padding: 16px;
+}
+
+.scene-title-row,
+.panel-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.sorter-svg {
+ width: 100%;
+ height: 588px;
+ border-radius: 16px;
+ background: #060d16;
+}
+
+.zone rect {
+ fill: rgba(14, 27, 43, 0.82);
+ stroke-width: 2;
+}
+
+.zone text,
+.device text,
+.gate text,
+.pusher text,
+.item-label,
+.scale-label {
+ fill: #b8c8da;
+ font-size: 14px;
+ font-weight: 700;
+ text-anchor: middle;
+}
+
+.zone-a rect { stroke: #38bdf8; }
+.zone-b rect { stroke: #4ade80; }
+.zone-c rect { stroke: #f59e0b; }
+.zone-d rect { stroke: #c084fc; }
+
+.device rect,
+.device circle {
+ fill: #0f2031;
+ stroke: #31526d;
+ stroke-width: 2;
+}
+
+.device line {
+ stroke: #31526d;
+ stroke-width: 2;
+ stroke-dasharray: 6 5;
+}
+
+.device.active rect,
+.device.active circle {
+ stroke: var(--cyan);
+ filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.7));
+}
+
+.device.active line {
+ stroke: var(--cyan);
+}
+
+.gate line {
+ stroke-width: 7;
+ stroke-linecap: round;
+}
+
+.gate.open line { stroke: var(--green); transform: rotate(22deg); transform-origin: 680px 315px; }
+.gate.closed line { stroke: var(--red); }
+
+.pusher rect {
+ fill: #101d2d;
+ stroke: #395873;
+ stroke-width: 2;
+}
+
+.pusher.extended rect {
+ stroke: var(--orange);
+ filter: drop-shadow(0 0 12px rgba(245, 158, 11, 0.55));
+}
+
+.right-rail {
+ display: grid;
+ gap: 16px;
+}
+
+.compact-panel,
+.metrics-panel,
+.pid-panel,
+.event-panel,
+.scenario-panel {
+ padding: 14px;
+}
+
+.kv-grid {
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ gap: 9px 12px;
+ font-size: 13px;
+}
+
+.kv-grid span,
+.muted {
+ color: var(--muted);
+}
+
+.kv-grid strong {
+ font-size: 13px;
+}
+
+.category-B { color: var(--green); }
+.category-C { color: var(--orange); }
+.category-D { color: var(--purple); }
+
+.decision-tree {
+ display: grid;
+ gap: 8px;
+ padding-left: 20px;
+ color: #b8c8da;
+ font-size: 13px;
+}
+
+.decision-tree li {
+ padding: 8px 10px;
+ border: 1px solid #243e57;
+ border-radius: 10px;
+ background: #0b1725;
+}
+
+.decision-tree .pass { border-color: rgba(74, 222, 128, 0.65); }
+.decision-tree .fail { border-color: rgba(245, 158, 11, 0.7); }
+
+.state-list {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 7px;
+}
+
+.state {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 7px 8px;
+ border: 1px solid #243e57;
+ border-radius: 9px;
+ color: #95a7bc;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.state span {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #33485c;
+}
+
+.state.active {
+ color: #f4fbff;
+ border-color: var(--cyan);
+ background: rgba(56, 189, 248, 0.11);
+}
+
+.state.active span {
+ background: var(--cyan);
+ box-shadow: 0 0 14px var(--cyan);
+}
+
+.sensor-stack {
+ display: grid;
+ gap: 8px;
+}
+
+.sensor-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px;
+ border: 1px solid #243e57;
+ border-radius: 12px;
+ background: #0b1725;
+}
+
+.sensor-row.active {
+ border-color: var(--cyan);
+ box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.2);
+}
+
+.sensor-row strong,
+.sensor-row span,
+.sensor-meta span {
+ display: block;
+ font-size: 12px;
+}
+
+.sensor-row span,
+.sensor-meta span {
+ color: var(--muted);
+}
+
+.sensor-meta {
+ text-align: right;
+}
+
+.actuator-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.actuator-grid div,
+.pid-bars div {
+ padding: 9px;
+ border: 1px solid #243e57;
+ border-radius: 12px;
+ background: #081422;
+}
+
+.actuator-grid span,
+.pid-bars span {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.actuator-grid strong,
+.pid-bars strong {
+ font-size: 13px;
+}
+
+.bottom-row {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: 1.1fr 0.75fr 1.3fr 1fr;
+ gap: 16px;
+}
+
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.metric-card {
+ min-height: 78px;
+ padding: 10px;
+ border: 1px solid #243e57;
+ border-radius: 14px;
+ background: linear-gradient(180deg, #0b1725, #07111d);
+}
+
+.metric-card span,
+.metric-card em {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ font-style: normal;
+}
+
+.metric-card strong {
+ display: block;
+ margin: 5px 0;
+ font-size: 22px;
+ letter-spacing: -0.04em;
+}
+
+.pid-bars {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+}
+
+.speed-track {
+ height: 10px;
+ margin: 12px 0;
+ overflow: hidden;
+ border-radius: 999px;
+ background: #102034;
+}
+
+.speed-track div {
+ height: 100%;
+ background: linear-gradient(90deg, var(--cyan), var(--green));
+}
+
+.event-table {
+ overflow: auto;
+ max-height: 290px;
+ border: 1px solid #243e57;
+ border-radius: 14px;
+}
+
+.event-row {
+ display: grid;
+ grid-template-columns: 68px 92px 94px minmax(240px, 1fr) 72px 72px;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid #172d42;
+ color: #c8d7e8;
+ font-size: 12px;
+}
+
+.event-head {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: #0d1b2b;
+ color: var(--muted);
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.event-row.warning { color: #facc15; }
+.event-row.error { color: #fb7185; }
+.event-row.success { color: #86efac; }
+
+.empty-log {
+ padding: 16px;
+ color: var(--muted);
+}
+
+.scenario-buttons {
+ display: grid;
+ gap: 8px;
+ max-height: 290px;
+ overflow: auto;
+}
+
+.scenario-buttons button {
+ padding: 10px;
+ text-align: left;
+}
+
+.scenario-buttons button strong,
+.scenario-buttons button span {
+ display: block;
+}
+
+.scenario-buttons button span {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.scenario-buttons button.active {
+ border-color: var(--cyan);
+ background: rgba(56, 189, 248, 0.12);
+}
+
+@media (max-width: 1280px) {
+ body {
+ min-width: 0;
+ }
+
+ .dashboard-grid,
+ .bottom-row {
+ grid-template-columns: 1fr;
+ }
+
+ .right-rail {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..5477feb
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..4334f1e
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..948011f
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: '127.0.0.1',
+ port: 3100,
+ },
+});