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

+
+
    +
  1. + 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 +
  2. +
  3. + roundness check: threshold < {DIMENSION_LIMITS.roundnessThreshold} +
  4. +
  5. + result: {result ? `${result.category} - ${result.label}` : 'waiting for item'} +
  6. +
+
+ ); +} 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 ( +
+
+

Intelligent robotic sorting line

+

OZON Tech Sorter Simulation

+
+
+
+ + {simulation.systemStatus} +
+
Scenario: {simulation.scenario.name}
+
+ + + + +
+
+
+ ); +} 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}
+
+ + + + + + + + + + + + + + + + + + + + + + A feed + + + + B main sorter + + + + D shape / repack + + + + C oversize cage + + + + + {Array.from({ length: 16 }).map((_, index) => ( + + ))} + + + + + Camera + + + + + Laser + + + + + Ultrasound + + + + + Stop-gate {simulation.gate.open ? 'open' : 'closed'} + + + + + Pusher C + + + + Pusher D + + + {routeVisible && category === 'B' ? : null} + {routeVisible && category === 'C' ? : null} + {routeVisible && category === 'D' ? : null} + + {current ? ( + + + {current.item.id} + {detecting ? ( + + ) : null} + + ) : null} + + Conveyor width 500 mm / height 700 mm + SVG scene is scaled for operator overview + +
+ ); +} 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, + }, +});