feat: add sorter simulation dashboard MVP
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
11
Dockerfile
Normal 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
131
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 сортировки.
|
||||
|
||||
6
docker-compose.server.yml
Normal file
6
docker-compose.server.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:3100:80"
|
||||
restart: unless-stopped
|
||||
13
index.html
Normal file
13
index.html
Normal 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
944
package-lock.json
generated
Normal 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
23
package.json
Normal 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
51
src/App.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
src/components/ClassificationPanel.tsx
Normal file
26
src/components/ClassificationPanel.tsx
Normal 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 < {DIMENSION_LIMITS.roundnessThreshold}
|
||||
</li>
|
||||
<li className={result ? `category-${result.category}` : ''}>
|
||||
result: {result ? `${result.category} - ${result.label}` : 'waiting for item'}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
src/components/CurrentItemPanel.tsx
Normal file
27
src/components/CurrentItemPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/Dashboard.tsx
Normal file
66
src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/EventLog.tsx
Normal file
33
src/components/EventLog.tsx
Normal 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
33
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/MetricsPanel.tsx
Normal file
35
src/components/MetricsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/PidPanel.tsx
Normal file
42
src/components/PidPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/ScenarioPanel.tsx
Normal file
30
src/components/ScenarioPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/SensorPanel.tsx
Normal file
37
src/components/SensorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/components/SorterScene.tsx
Normal file
163
src/components/SorterScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/StateMachinePanel.tsx
Normal file
35
src/components/StateMachinePanel.tsx
Normal 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
102
src/data/items.ts
Normal 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
60
src/data/scenarios.ts
Normal 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
52
src/domain/classifier.ts
Normal 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
31
src/domain/metrics.ts
Normal 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
24
src/domain/pid.ts
Normal 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
408
src/domain/simulation.ts
Normal 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
173
src/domain/types.ts
Normal 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
10
src/main.tsx
Normal 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
520
src/styles.css
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
10
vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user