feat: add sorter simulation dashboard MVP

This commit is contained in:
deploy
2026-07-04 00:26:08 +02:00
parent 482be98b49
commit 9395d25332
33 changed files with 3141 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
npm-debug.log
.DS_Store

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
.env
.env.*
npm-debug.log*

11
Dockerfile Normal file
View File

@@ -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;"]

131
README.md
View File

@@ -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 сортировки.

View File

@@ -0,0 +1,6 @@
services:
web:
build: .
ports:
- "127.0.0.1:3100:80"
restart: unless-stopped

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#070b12" />
<title>OZON Tech Sorter Simulation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

944
package-lock.json generated Normal file
View File

@@ -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
}
}
}
}
}

23
package.json Normal file
View File

@@ -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"
}
}

51
src/App.tsx Normal file
View File

@@ -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<ScenarioId>('normal_flow');
const activeScenario = useMemo(
() => SCENARIOS.find((scenario) => scenario.id === activeScenarioId) ?? SCENARIOS[0],
[activeScenarioId],
);
const [simulation, setSimulation] = useState<SimulationState>(() => 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 (
<Dashboard
simulation={simulation}
scenarios={SCENARIOS}
activeScenarioId={activeScenarioId}
onStart={handleStart}
onPause={handlePause}
onReset={handleReset}
onStep={handleStep}
onScenarioChange={handleScenarioChange}
/>
);
}

View File

@@ -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 (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">Classification rules</p>
<h2>Decision tree</h2>
</div>
<ol className="decision-tree">
<li className={result ? (result.dimensionsPass ? 'pass' : 'fail') : ''}>
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
</li>
<li className={result && result.dimensionsPass ? (result.roundnessPass ? 'pass' : 'fail') : ''}>
roundness check: threshold &lt; {DIMENSION_LIMITS.roundnessThreshold}
</li>
<li className={result ? `category-${result.category}` : ''}>
result: {result ? `${result.category} - ${result.label}` : 'waiting for item'}
</li>
</ol>
</section>
);
}

View File

@@ -0,0 +1,27 @@
import type { SimulationState } from '../domain/types';
export default function CurrentItemPanel({ simulation }: { simulation: SimulationState }) {
const current = simulation.currentItem;
return (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">Current item</p>
<h2>{current?.item.id ?? 'No active item'}</h2>
</div>
{current ? (
<div className="kv-grid">
<span>Name</span><strong>{current.item.name}</strong>
<span>Dimensions</span><strong>{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm</strong>
<span>Height</span><strong>{current.item.dimensionsMm.height} mm</strong>
<span>Roundness</span><strong>{current.item.roundness.toFixed(2)}</strong>
<span>Confidence</span><strong>{Math.round(current.item.confidence * 100)}%</strong>
<span>Category</span><strong className={`category-${current.classification.category}`}>{current.classification.category} / {current.classification.label}</strong>
<span>Reason</span><strong>{current.classification.reason}</strong>
</div>
) : (
<p className="muted">Start or step the simulation to feed an item into zone A.</p>
)}
</section>
);
}

View File

@@ -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 (
<div className="app-shell">
<Header
simulation={simulation}
onStart={onStart}
onPause={onPause}
onReset={onReset}
onStep={onStep}
/>
<main className="dashboard-grid">
<section className="scene-column panel panel-scene">
<SorterScene simulation={simulation} />
</section>
<aside className="right-rail">
<CurrentItemPanel simulation={simulation} />
<ClassificationPanel currentItem={simulation.currentItem} />
<StateMachinePanel currentState={simulation.machineState} />
<SensorPanel simulation={simulation} />
</aside>
<section className="bottom-row">
<MetricsPanel metrics={simulation.metrics} />
<PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} />
<EventLog events={simulation.events} />
<ScenarioPanel
scenarios={scenarios}
activeScenarioId={activeScenarioId}
onScenarioChange={onScenarioChange}
/>
</section>
</main>
</div>
);
}

View File

@@ -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 (
<section className="panel event-panel">
<div className="panel-heading">
<p className="eyebrow">Event log</p>
<h2>Cycle trace</h2>
</div>
<div className="event-table" role="table">
<div className="event-row event-head" role="row">
<span>time</span><span>item</span><span>type</span><span>message</span><span>category</span><span>status</span>
</div>
{events.length === 0 ? (
<div className="empty-log">No events yet. Press Start or Step.</div>
) : events.map((entry) => (
<div className={`event-row ${entry.status}`} role="row" key={entry.id}>
<span>{formatTime(entry.timestampMs)}</span>
<span>{entry.itemId ?? '-'}</span>
<span>{entry.type}</span>
<span>{entry.message}</span>
<span>{entry.category ?? '-'}</span>
<span>{entry.status}</span>
</div>
))}
</div>
</section>
);
}

33
src/components/Header.tsx Normal file
View File

@@ -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 (
<header className="header">
<div>
<p className="eyebrow">Intelligent robotic sorting line</p>
<h1>OZON Tech Sorter Simulation</h1>
</div>
<div className="header-status">
<div className={`status-pill status-${simulation.systemStatus.toLowerCase()}`}>
<span />
{simulation.systemStatus}
</div>
<div className="scenario-label">Scenario: {simulation.scenario.name}</div>
<div className="header-actions">
<button onClick={onStart}>Start</button>
<button onClick={onPause}>Pause</button>
<button onClick={onReset}>Reset</button>
<button onClick={onStep}>Step</button>
</div>
</div>
</header>
);
}

View File

@@ -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 (
<section className="panel metrics-panel">
<div className="panel-heading">
<p className="eyebrow">Metrics</p>
<h2>Line telemetry</h2>
</div>
<div className="metric-grid">
{metricLabels.map(([key, label, unit]) => (
<div className="metric-card" key={key}>
<span>{label}</span>
<strong>{metrics[key]}</strong>
<em>{unit}</em>
</div>
))}
</div>
</section>
);
}

View File

@@ -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 (
<section className="panel pid-panel">
<div className="panel-heading">
<p className="eyebrow">Conveyor control loop</p>
<h2>PID imitation</h2>
</div>
<div className="pid-bars">
<div>
<span>targetSpeedMps</span>
<strong>{pid.targetSpeedMps.toFixed(2)}</strong>
</div>
<div>
<span>actualSpeedMps</span>
<strong>{pid.actualSpeedMps.toFixed(2)}</strong>
</div>
<div>
<span>pidError</span>
<strong>{pid.pidError.toFixed(2)}</strong>
</div>
<div>
<span>correction</span>
<strong>{pid.correction.toFixed(2)}</strong>
</div>
</div>
<div className="speed-track">
<div style={{ width: `${fill}%` }} />
</div>
<p className="muted">Status {status}: actual speed follows target during flow and decays to zero on jam/emergency.</p>
</section>
);
}

View File

@@ -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 (
<section className="panel scenario-panel">
<div className="panel-heading">
<p className="eyebrow">Scenario panel</p>
<h2>Test cases</h2>
</div>
<div className="scenario-buttons">
{scenarios.map((scenario) => (
<button
key={scenario.id}
className={scenario.id === activeScenarioId ? 'active' : ''}
onClick={() => onScenarioChange(scenario.id)}
>
<strong>{scenario.name}</strong>
<span>{scenario.description}</span>
</button>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import type { SensorState, SimulationState } from '../domain/types';
function SensorRow({ sensor }: { sensor: SensorState }) {
return (
<div className={sensor.active ? 'sensor-row active' : 'sensor-row'}>
<div>
<strong>{sensor.label}</strong>
<span>{sensor.lastValue}</span>
</div>
<div className="sensor-meta">
<span>{sensor.latencyMs} ms</span>
<span>t+{(sensor.lastEventTimestampMs / 1000).toFixed(1)}s</span>
</div>
</div>
);
}
export default function SensorPanel({ simulation }: { simulation: SimulationState }) {
return (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">Sensors and actuators</p>
<h2>Live I/O</h2>
</div>
<div className="sensor-stack">
<SensorRow sensor={simulation.sensors.camera} />
<SensorRow sensor={simulation.sensors.laser} />
<SensorRow sensor={simulation.sensors.ultrasound} />
</div>
<div className="actuator-grid">
<div><span>Stop-gate</span><strong>{simulation.gate.open ? 'open' : 'closed'}</strong></div>
<div><span>Pusher C</span><strong>{simulation.actuators.pusherC}</strong></div>
<div><span>Pusher D</span><strong>{simulation.actuators.pusherD}</strong></div>
</div>
</section>
);
}

View File

@@ -0,0 +1,163 @@
import type { Category, MachineState, SimulationState } from '../domain/types';
const routeColors: Record<Category, string> = {
B: '#4ade80',
C: '#f59e0b',
D: '#c084fc',
};
function progressForState(state: MachineState, elapsedMs: number): number {
const ratios: Partial<Record<MachineState, [number, number, number]>> = {
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 (
<div className="scene-wrap">
<div className="scene-title-row">
<div>
<p className="eyebrow">Work zone 6000 x 10000 mm</p>
<h2>Sorting line digital twin</h2>
</div>
<div className="machine-state-chip">{simulation.machineState}</div>
</div>
<svg viewBox="0 0 1000 620" role="img" aria-label="Sorter simulation scene" className="sorter-svg">
<defs>
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#183044" strokeWidth="1" />
</pattern>
<marker id="arrowB" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4ade80" />
</marker>
<marker id="arrowC" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#f59e0b" />
</marker>
<marker id="arrowD" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#c084fc" />
</marker>
</defs>
<rect x="20" y="20" width="960" height="560" rx="18" fill="#08111d" stroke="#1f3b54" />
<rect x="40" y="40" width="920" height="520" fill="url(#grid)" opacity="0.75" />
<g className="zone zone-a">
<rect x="70" y="246" width="120" height="138" rx="14" />
<text x="130" y="238">A feed</text>
</g>
<g className="zone zone-b">
<rect x="805" y="246" width="125" height="138" rx="14" />
<text x="868" y="238">B main sorter</text>
</g>
<g className="zone zone-d">
<rect x="610" y="76" width="190" height="110" rx="14" />
<text x="705" y="68">D shape / repack</text>
</g>
<g className="zone zone-c">
<rect x="610" y="444" width="190" height="110" rx="14" />
<text x="705" y="574">C oversize cage</text>
</g>
<rect x="90" y="275" width="810" height="80" rx="12" fill="#111f2e" stroke="#38526d" strokeWidth="2" />
<line x1="110" y1="315" x2="880" y2="315" stroke="#5c7087" strokeWidth="2" strokeDasharray="18 12" />
{Array.from({ length: 16 }).map((_, index) => (
<line key={index} x1={125 + index * 48} y1="284" x2={148 + index * 48} y2="346" stroke="#21374c" strokeWidth="3" />
))}
<g className={simulation.sensors.camera.active ? 'device active' : 'device'}>
<rect x="345" y="188" width="70" height="48" rx="8" />
<line x1="380" y1="236" x2="380" y2="275" />
<text x="380" y="180">Camera</text>
</g>
<g className={simulation.sensors.laser.active ? 'device active' : 'device'}>
<rect x="465" y="188" width="70" height="48" rx="8" />
<line x1="500" y1="236" x2="500" y2="275" />
<text x="500" y="180">Laser</text>
</g>
<g className={simulation.sensors.ultrasound.active ? 'device active' : 'device'}>
<circle cx="620" cy="215" r="26" />
<line x1="620" y1="241" x2="620" y2="275" />
<text x="620" y="180">Ultrasound</text>
</g>
<g className={simulation.gate.open ? 'gate open' : 'gate closed'}>
<line x1="680" y1="258" x2="680" y2="372" />
<text x="706" y="263">Stop-gate {simulation.gate.open ? 'open' : 'closed'}</text>
</g>
<g className={`pusher ${simulation.actuators.pusherC}`}>
<rect x="600" y="372" width="160" height="32" rx="8" />
<text x="680" y="426">Pusher C</text>
</g>
<g className={`pusher ${simulation.actuators.pusherD}`}>
<rect x="600" y="226" width="160" height="32" rx="8" />
<text x="680" y="220">Pusher D</text>
</g>
{routeVisible && category === 'B' ? <line x1="690" y1="315" x2="875" y2="315" stroke="#4ade80" strokeWidth="5" markerEnd="url(#arrowB)" /> : null}
{routeVisible && category === 'C' ? <line x1="690" y1="330" x2="705" y2="485" stroke="#f59e0b" strokeWidth="5" markerEnd="url(#arrowC)" /> : null}
{routeVisible && category === 'D' ? <line x1="690" y1="300" x2="705" y2="145" stroke="#c084fc" strokeWidth="5" markerEnd="url(#arrowD)" /> : null}
{current ? (
<g>
<rect
x={position.x - 28}
y={position.y - 24}
width="56"
height="48"
rx={current.item.shape.includes('round') || current.item.shape.includes('cylinder') ? 24 : 8}
fill={color}
opacity="0.92"
stroke="#ffffff"
strokeWidth="1.5"
/>
<text x={position.x} y={position.y + 44} className="item-label">{current.item.id}</text>
{detecting ? (
<rect x={position.x - 39} y={position.y - 34} width="78" height="68" fill="none" stroke="#38bdf8" strokeWidth="2" strokeDasharray="6 5" />
) : null}
</g>
) : null}
<text x="80" y="535" className="scale-label">Conveyor width 500 mm / height 700 mm</text>
<text x="80" y="555" className="scale-label">SVG scene is scaled for operator overview</text>
</svg>
</div>
);
}

View File

@@ -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 (
<section className="panel compact-panel">
<div className="panel-heading">
<p className="eyebrow">State machine</p>
<h2>{currentState}</h2>
</div>
<div className="state-list">
{states.map((state) => (
<div key={state} className={state === currentState ? 'state active' : 'state'}>
<span />
{state}
</div>
))}
</div>
</section>
);
}

102
src/data/items.ts Normal file
View File

@@ -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;
}

60
src/data/scenarios.ts Normal file
View File

@@ -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];
}

52
src/domain/classifier.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Category, CategoryDefinition, ClassificationResult, Item } from './types';
export const CATEGORY_DEFINITIONS: Record<Category, CategoryDefinition> = {
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,
};
}

31
src/domain/metrics.ts Normal file
View File

@@ -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)),
};
}

24
src/domain/pid.ts Normal file
View File

@@ -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)),
};
}

408
src/domain/simulation.ts Normal file
View File

@@ -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<Record<MachineState, number>> = {
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<Category, MachineState> = {
B: 'ROUTE_TO_B',
C: 'ROUTE_TO_C',
D: 'ROUTE_TO_D',
};
let eventCounter = 0;
function event(timestampMs: number, entry: Omit<EventLogEntry, 'id' | 'timestampMs'>): EventLogEntry {
eventCounter += 1;
return {
id: `evt-${eventCounter}`,
timestampMs,
...entry,
};
}
function appendEvent(state: SimulationState, entry: Omit<EventLogEntry, 'id' | 'timestampMs'>): 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);
}

173
src/domain/types.ts Normal file
View File

@@ -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;
}

10
src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);

520
src/styles.css Normal file
View File

@@ -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));
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View File

@@ -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" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@@ -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,
},
});