feat: enhance sorter simulation demo
This commit is contained in:
189
README.md
189
README.md
@@ -2,91 +2,20 @@
|
||||
|
||||
Инженерный frontend MVP для задачи хакатона OZON Tech: «Интеллектуальная роботизированная система сортировки товаров».
|
||||
|
||||
Приложение показывает 2D/SVG dashboard сортировочной линии: конвейер, рабочую зону 6000 x 10000 мм, зоны A/B/C/D, датчики, stop-gate, боковые толкатели, движение товаров, классификацию, маршрутизацию, state machine, метрики, PID-виджет и event log.
|
||||
Проект показывает 2D/SVG dashboard сортировочной линии: рабочую зону 6000 x 10000 мм, конвейер шириной 500 мм, зоны A/B/C/D, roll-cage C/D, датчики, stop-gate, толкатели, классификацию, маршрутизацию, timeline цикла, PID-имитацию, метрики и event log.
|
||||
|
||||
## Архитектура решения
|
||||
## Стек
|
||||
|
||||
- Vite + React + TypeScript.
|
||||
- Доменная логика вынесена в `src/domain`.
|
||||
- Mock-данные товаров и сценариев находятся в `src/data`.
|
||||
- UI собран из независимых панелей в `src/components`.
|
||||
- Визуализация линии выполнена на SVG без Three.js и тяжелой 3D-графики.
|
||||
- Vite
|
||||
- React
|
||||
- TypeScript
|
||||
- SVG/CSS для инженерной 2D-сцены
|
||||
- Vitest для доменных тестов
|
||||
- Docker + nginx для production static hosting
|
||||
|
||||
## Логика классификации
|
||||
Нет Three.js, backend, реального ML и тяжелых UI-библиотек.
|
||||
|
||||
Классификация реализована чистой функцией `classifyItem`.
|
||||
|
||||
1. Сначала проверяются допустимые габариты.
|
||||
2. Если товар нарушает min/max размеры, он относится к C.
|
||||
3. Если габариты подходят, проверяется `roundness`.
|
||||
4. Если `roundness >= 0.8`, товар относится к D.
|
||||
5. Иначе товар относится к B.
|
||||
6. Если объект одновременно негабаритный и круглый, приоритет остается у C.
|
||||
|
||||
Допустимые габариты MVP:
|
||||
|
||||
- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм;
|
||||
- max: width <= 450 мм, depth <= 320 мм, height <= 320 мм;
|
||||
- roundness threshold: 0.8.
|
||||
|
||||
## Исполнительная часть
|
||||
|
||||
Симуляция отображает:
|
||||
|
||||
- камеру с pseudo-CV, bounding box и confidence;
|
||||
- лазерный датчик высоты;
|
||||
- ультразвуковой датчик прибытия к stop-gate;
|
||||
- stop-gate с состояниями open/closed;
|
||||
- pusher C и pusher D со состояниями idle/extended/retracting;
|
||||
- маршруты B/C/D с цветовой индикацией;
|
||||
- предупреждения для close items и low confidence;
|
||||
- fault/emergency сценарии с остановкой конвейера.
|
||||
|
||||
## State machine
|
||||
|
||||
Состояния MVP:
|
||||
|
||||
- IDLE
|
||||
- MOVING_TO_CAMERA
|
||||
- DETECTING
|
||||
- MOVING_TO_GATE
|
||||
- WAITING_AT_GATE
|
||||
- CLASSIFYING
|
||||
- ROUTE_TO_B
|
||||
- ROUTE_TO_C
|
||||
- ROUTE_TO_D
|
||||
- RETURN_HOME
|
||||
- FAULT
|
||||
- EMERGENCY_STOP
|
||||
|
||||
## Сценарии
|
||||
|
||||
- `normal_flow` — обычный поток товаров B/C/D.
|
||||
- `oversized_item` — товар выходит за max dimensions и уходит в C.
|
||||
- `round_object` — товар проходит по габаритам, но имеет roundness >= 0.8 и уходит в D.
|
||||
- `boundary_dimensions` — товары на границах допустимых размеров.
|
||||
- `close_items` — два товара близко друг к другу, появляется warning queue/spacing.
|
||||
- `low_confidence` — confidence ниже 0.65, решение принимается rule-based.
|
||||
- `jam` — застревание у stop-gate, state FAULT, conveyor stopped.
|
||||
- `emergency_stop` — аварийная остановка, state EMERGENCY_STOP.
|
||||
|
||||
## Метрики
|
||||
|
||||
Dashboard показывает:
|
||||
|
||||
- processed count;
|
||||
- success count;
|
||||
- error count;
|
||||
- avg cycle time;
|
||||
- throughput items/min;
|
||||
- cv latency;
|
||||
- actuator latency;
|
||||
- queue length;
|
||||
- conveyor speed;
|
||||
- PID target speed;
|
||||
- PID actual speed.
|
||||
|
||||
## Локальный запуск
|
||||
## Запуск локально
|
||||
|
||||
```bash
|
||||
npm install
|
||||
@@ -95,7 +24,7 @@ npm run dev
|
||||
|
||||
Открыть: http://127.0.0.1:3100/
|
||||
|
||||
## Запуск на сервере
|
||||
## Запуск через Docker
|
||||
|
||||
```bash
|
||||
docker compose -p owl -f docker-compose.server.yml up -d --build
|
||||
@@ -107,25 +36,97 @@ Compose публикует только loopback-порт:
|
||||
127.0.0.1:3100:80
|
||||
```
|
||||
|
||||
## Как проверить
|
||||
## Проверка домена
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:3100/
|
||||
curl -I https://arhipovdan.ru/
|
||||
curl -I https://www.arhipovdan.ru/
|
||||
```
|
||||
|
||||
## Сценарии
|
||||
|
||||
- `normal_flow` — обычный поток B/C/D.
|
||||
- `oversized_item` — max dimensions нарушены, маршрут C.
|
||||
- `round_object` — габариты проходят, roundness >= 0.8, маршрут D.
|
||||
- `boundary_dimensions` — проверка min/max границ.
|
||||
- `close_items` — предупреждение spacing/queue, последовательная обработка.
|
||||
- `low_confidence` — низкий CV confidence, rule-based fallback.
|
||||
- `jam` — застревание у gate, FAULT, остановка конвейера.
|
||||
- `emergency_stop` — EMERGENCY_STOP, остановка всех движений.
|
||||
|
||||
## Классификация
|
||||
|
||||
Классификация реализована чистой функцией `classifyItem`.
|
||||
|
||||
1. Проверяются габариты.
|
||||
2. Если нарушены min/max размеры, категория C.
|
||||
3. Если габариты подходят, проверяется `roundness`.
|
||||
4. Если `roundness >= 0.8`, категория D.
|
||||
5. Иначе категория B.
|
||||
6. Если товар одновременно негабаритный и круглый, приоритет у C, потому что dimensions check идет первым.
|
||||
|
||||
Границы MVP:
|
||||
|
||||
- min: width >= 10 мм, depth >= 10 мм, height >= 2 мм;
|
||||
- max: width <= 450 мм, depth <= 320 мм, height <= 320 мм;
|
||||
- roundness threshold: 0.8.
|
||||
|
||||
## Исполнительная часть
|
||||
|
||||
State machine управляет циклом:
|
||||
|
||||
- `MOVING_TO_CAMERA`
|
||||
- `DETECTING`
|
||||
- `MOVING_TO_GATE`
|
||||
- `WAITING_AT_GATE`
|
||||
- `CLASSIFYING`
|
||||
- `ROUTE_TO_B/C/D`
|
||||
- `RETURN_HOME`
|
||||
- `FAULT`
|
||||
- `EMERGENCY_STOP`
|
||||
|
||||
Датчики имитируются по mock-данным: camera bbox/confidence/CV latency, laser measured height, ultrasonic gate detection. Stop-gate закрывается перед классификацией, открывается для B и удерживает товар для C/D перед толкателями.
|
||||
|
||||
## Simplified PID
|
||||
|
||||
PID-панель показывает упрощенную имитацию control loop:
|
||||
|
||||
- target speed;
|
||||
- actual speed;
|
||||
- error;
|
||||
- correction;
|
||||
- mini graph последних тиков скорости.
|
||||
|
||||
В normal flow actual speed приближается к target. В `jam` и `emergency_stop` target становится 0, actual speed визуально падает к 0.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Покрыты classifier, PID и целостность сценариев.
|
||||
|
||||
## Документация
|
||||
|
||||
- `docs/ARCHITECTURE.md` — модули и поток данных.
|
||||
- `docs/DEMO_SCRIPT.md` — 3-5 минутный сценарий защиты.
|
||||
- `docs/SCENARIOS.md` — ожидаемые результаты сценариев.
|
||||
- `docs/JURY_QA.md` — короткие ответы на вопросы жюри.
|
||||
|
||||
## Ограничения MVP
|
||||
|
||||
- Физика движения упрощена до дискретной state-machine симуляции.
|
||||
- PID-регулятор имитационный, без точной модели двигателя и нагрузки.
|
||||
- CV, laser и ultrasound генерируют псевдоизмерения на основе mock-данных.
|
||||
- Нет backend, real-time API и сохранения событий.
|
||||
- Нет Three.js и полноценного digital twin в 3D.
|
||||
- Физика движения дискретная, без динамической модели массы/трения.
|
||||
- CV является pseudo-CV по mock-данным.
|
||||
- PID упрощен до демонстрации стабилизации скорости.
|
||||
- Нет backend, real-time API, сохранения событий и реального ML.
|
||||
- Нет 3D digital twin.
|
||||
|
||||
## Что улучшить дальше
|
||||
|
||||
- Добавить backend с WebSocket-телеметрией.
|
||||
- Подключить реальные ML/CV модели или replay датасетов.
|
||||
- Сделать режим ручного управления исполнительными механизмами.
|
||||
- Добавить тесты доменной логики и сценариев.
|
||||
- Расширить физическую модель очереди, зазоров и jam recovery.
|
||||
- Добавить экспорт event log и отчеты по SLA сортировки.
|
||||
- Добавить WebSocket-телеметрию и replay реальных событий.
|
||||
- Подключить реальные CV-модели или датасеты.
|
||||
- Добавить режим manual override для gate/pushers.
|
||||
- Расширить модель очереди, spacing и recovery после jam.
|
||||
- Экспортировать event log в отчет смены.
|
||||
|
||||
71
docs/ARCHITECTURE.md
Normal file
71
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Architecture
|
||||
|
||||
## Module Map
|
||||
|
||||
```text
|
||||
src/data/items.ts
|
||||
src/data/scenarios.ts
|
||||
|
|
||||
v
|
||||
src/domain/classifier.ts -> src/domain/simulation.ts -> React UI components
|
||||
| |
|
||||
| +-> metrics, PID, sensors, event log
|
||||
v
|
||||
classification result
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Scenario selects a sequence of mock items.
|
||||
2. Simulation feeds one item at a time into zone A.
|
||||
3. Pseudo-CV and sensors derive measurements from item data.
|
||||
4. `classifyItem` applies deterministic rules.
|
||||
5. State machine commands gate and pushers.
|
||||
6. UI renders SVG scene, panels, metrics, timeline and event log.
|
||||
|
||||
## Pseudo-CV
|
||||
|
||||
The MVP does not run real ML. Camera output is generated from item dimensions:
|
||||
|
||||
- bbox width/depth;
|
||||
- confidence;
|
||||
- CV latency;
|
||||
- detected dimensions.
|
||||
|
||||
Low confidence is not ignored: the event log and classification panel show a warning and explain rule-based fallback.
|
||||
|
||||
## Sensor Simulation
|
||||
|
||||
- Camera is active in `DETECTING`.
|
||||
- Laser is active during `DETECTING` and `MOVING_TO_GATE`.
|
||||
- Ultrasonic sensor is active at `WAITING_AT_GATE` and `CLASSIFYING`.
|
||||
|
||||
Each sensor keeps active state, last value, latency and last event timestamp.
|
||||
|
||||
## Actuator Control
|
||||
|
||||
- Stop-gate closes at `WAITING_AT_GATE`.
|
||||
- B route opens the gate and sends item straight.
|
||||
- C route keeps the gate closed and extends pusher C.
|
||||
- D route keeps the gate closed and extends pusher D.
|
||||
- `RETURN_HOME` retracts mechanisms.
|
||||
|
||||
## State Machine
|
||||
|
||||
Main cycle:
|
||||
|
||||
```text
|
||||
IDLE -> MOVING_TO_CAMERA -> DETECTING -> MOVING_TO_GATE -> WAITING_AT_GATE
|
||||
-> CLASSIFYING -> ROUTE_TO_B/C/D -> RETURN_HOME -> next item or IDLE
|
||||
```
|
||||
|
||||
Fault states:
|
||||
|
||||
- `FAULT` for jam at gate;
|
||||
- `EMERGENCY_STOP` for emergency stop scenario.
|
||||
|
||||
Both stop conveyor motion and require Reset.
|
||||
|
||||
## Metrics
|
||||
|
||||
The dashboard tracks processed count, success/error count, avg cycle time, throughput, CV latency, actuator latency, queue length, queue delay and conveyor speed.
|
||||
69
docs/DEMO_SCRIPT.md
Normal file
69
docs/DEMO_SCRIPT.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Demo Script
|
||||
|
||||
Target duration: 3-5 minutes.
|
||||
|
||||
## 1. Open The Dashboard
|
||||
|
||||
Open https://arhipovdan.ru/.
|
||||
|
||||
Explain that this is a 2D engineering simulation, not a decorative animation: the SVG scene shows a scaled work zone, conveyor dimensions, sensors, stop-gate, pushers and roll-cages.
|
||||
|
||||
## 2. Normal Flow
|
||||
|
||||
Select `Normal flow`, press `Start`.
|
||||
|
||||
Explain:
|
||||
|
||||
- item enters zone A;
|
||||
- camera captures bbox;
|
||||
- laser measures height;
|
||||
- ultrasonic confirms gate position;
|
||||
- gate holds item;
|
||||
- classifier selects B/C/D;
|
||||
- route command is shown on the scene;
|
||||
- event log records the full cycle.
|
||||
|
||||
Question closed: can the system show a full sorting cycle end-to-end?
|
||||
|
||||
## 3. Step-By-Step Decision
|
||||
|
||||
Press `Reset`, then use `Step state`.
|
||||
|
||||
Explain each state in the timeline. Show that Step advances by logical state, not by arbitrary animation time.
|
||||
|
||||
Question closed: can the jury inspect synchronization and state transitions?
|
||||
|
||||
## 4. Oversized And Round Rules
|
||||
|
||||
Select `Oversized item`, step to classification.
|
||||
|
||||
Show decision tree:
|
||||
|
||||
- dimensions check fails;
|
||||
- category C selected;
|
||||
- roundness is lower priority because dimensions are checked first.
|
||||
|
||||
Then select `Round object` and show D when dimensions pass and roundness >= 0.8.
|
||||
|
||||
Question closed: how is classification proved?
|
||||
|
||||
## 5. Robustness Scenarios
|
||||
|
||||
Show `close_items`, `low_confidence`, `jam`, `emergency_stop`.
|
||||
|
||||
Explain:
|
||||
|
||||
- close items generate spacing warning and queue length;
|
||||
- low confidence uses rule-based fallback;
|
||||
- jam enters FAULT and stops conveyor;
|
||||
- emergency stop enters EMERGENCY_STOP and requires Reset.
|
||||
|
||||
Question closed: what happens outside the happy path?
|
||||
|
||||
## 6. PID And Metrics
|
||||
|
||||
Point to PID panel and metrics cards.
|
||||
|
||||
Explain that PID is simplified: actual speed approaches target in normal flow and decays toward zero in fault/emergency.
|
||||
|
||||
Question closed: how is conveyor control represented without overbuilding physics?
|
||||
41
docs/JURY_QA.md
Normal file
41
docs/JURY_QA.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Jury Q&A
|
||||
|
||||
## 1. Why 2D, Not 3D?
|
||||
|
||||
2D is enough for MVP validation: it shows geometry, timing, sensor positions, routes and state transitions without spending effort on heavy rendering.
|
||||
|
||||
## 2. Where Is Computer Vision?
|
||||
|
||||
The MVP uses pseudo-CV: bbox, dimensions, confidence and latency are derived from mock items. The architecture keeps CV output separate from classification, so a real CV service can replace it later.
|
||||
|
||||
## 3. How Is Classification Correctness Proven?
|
||||
|
||||
The decision tree shows PASS/FAIL for dimensions and roundness, actual values, thresholds and final category. Tests cover key boundary cases.
|
||||
|
||||
## 4. How Are Dimensions And Circular Section Handled?
|
||||
|
||||
Dimensions are checked first against min/max width, depth and height. If they pass, roundness is checked against threshold 0.8.
|
||||
|
||||
## 5. Why Does C Have Priority Over D?
|
||||
|
||||
Oversized or undersized items are operationally unsafe for the main line and must be diverted first. Therefore dimensions check precedes roundness.
|
||||
|
||||
## 6. How Is Synchronization Shown?
|
||||
|
||||
Cycle timeline shows state order, simulated timestamps, durations and status: done, active, pending or skipped.
|
||||
|
||||
## 7. How Does The Actuator Part Work?
|
||||
|
||||
The stop-gate fixes the item. B opens the gate, C extends pusher C, D extends pusher D, then mechanisms return home.
|
||||
|
||||
## 8. What Happens On Jam?
|
||||
|
||||
The system enters FAULT, conveyor target speed becomes 0, actual speed decays toward 0, and Reset is required.
|
||||
|
||||
## 9. What Happens On Low CV Confidence?
|
||||
|
||||
A warning is logged, but the system still classifies by deterministic dimensions and roundness rules.
|
||||
|
||||
## 10. How To Scale This To A Real Hardware-Software System?
|
||||
|
||||
Replace pseudo-CV with a CV service, connect PLC/robot telemetry via backend/WebSocket, persist event logs, calibrate sensor latencies and add recovery policies.
|
||||
49
docs/SCENARIOS.md
Normal file
49
docs/SCENARIOS.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Scenarios
|
||||
|
||||
## normal_flow
|
||||
|
||||
Goal: demonstrate the full cycle across B/C/D routes.
|
||||
|
||||
Expected result: mixed B, C and D items are processed successfully.
|
||||
|
||||
## oversized_item
|
||||
|
||||
Goal: prove dimensions have first priority.
|
||||
|
||||
Expected result: every item routes to C.
|
||||
|
||||
## round_object
|
||||
|
||||
Goal: prove roundness check after dimensions.
|
||||
|
||||
Expected result: every item routes to D.
|
||||
|
||||
## boundary_dimensions
|
||||
|
||||
Goal: test min/max boundaries.
|
||||
|
||||
Expected result: `Boundary box 450x320x320` routes to B, `Pen 9x13x148` routes to C.
|
||||
|
||||
## close_items
|
||||
|
||||
Goal: demonstrate queue/spacing resilience.
|
||||
|
||||
Expected result: warning appears, queue length is shown, items are processed sequentially.
|
||||
|
||||
## low_confidence
|
||||
|
||||
Goal: demonstrate fallback when pseudo-CV confidence is below 0.65.
|
||||
|
||||
Expected result: warning appears, rule-based classification still selects B or D.
|
||||
|
||||
## jam
|
||||
|
||||
Goal: demonstrate fail-safe behavior at stop-gate.
|
||||
|
||||
Expected result: state becomes FAULT, conveyor speed target is 0, Reset is required.
|
||||
|
||||
## emergency_stop
|
||||
|
||||
Goal: demonstrate emergency stop.
|
||||
|
||||
Expected result: state becomes EMERGENCY_STOP, conveyor speed target is 0, Reset is required.
|
||||
395
package-lock.json
generated
395
package-lock.json
generated
@@ -17,7 +17,8 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3"
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
@@ -51,6 +52,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
@@ -326,6 +334,13 @@
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -336,6 +351,31 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
|
||||
@@ -391,6 +431,146 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -407,6 +587,33 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
|
||||
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -687,6 +894,16 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
@@ -705,6 +922,27 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -811,6 +1049,13 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -820,6 +1065,37 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -836,6 +1112,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -939,6 +1225,113 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 3100",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 3100"
|
||||
"preview": "vite preview --host 127.0.0.1 --port 3100",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
@@ -18,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3"
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import { SCENARIOS } from './data/scenarios';
|
||||
import { createSimulation, setRunning, stepSimulation } from './domain/simulation';
|
||||
import { createSimulation, setRunning, stepSimulation, stepSimulationToNextState } from './domain/simulation';
|
||||
import type { ScenarioId, SimulationState } from './domain/types';
|
||||
|
||||
const TICK_MS = 250;
|
||||
@@ -33,7 +33,7 @@ export default function App() {
|
||||
const handleStart = () => setSimulation((current) => setRunning(current, true));
|
||||
const handlePause = () => setSimulation((current) => setRunning(current, false));
|
||||
const handleReset = () => setSimulation(createSimulation(activeScenario));
|
||||
const handleStep = () => setSimulation((current) => stepSimulation(setRunning(current, false), 500, true));
|
||||
const handleStep = () => setSimulation((current) => stepSimulationToNextState(current));
|
||||
const handleScenarioChange = (scenarioId: ScenarioId) => setActiveScenarioId(scenarioId);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
import { DIMENSION_LIMITS } from '../domain/classifier';
|
||||
import type { SimulatedItem } from '../domain/types';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function ClassificationPanel({ currentItem }: { currentItem?: SimulatedItem }) {
|
||||
const result = currentItem?.classification;
|
||||
function Verdict({ pass, skipped = false }: { pass?: boolean; skipped?: boolean }) {
|
||||
if (skipped) {
|
||||
return <span className="verdict skipped">SKIPPED</span>;
|
||||
}
|
||||
if (pass === undefined) {
|
||||
return <span className="verdict pending">PENDING</span>;
|
||||
}
|
||||
return <span className={pass ? 'verdict pass' : 'verdict fail'}>{pass ? 'PASS' : 'FAIL'}</span>;
|
||||
}
|
||||
|
||||
export default function ClassificationPanel({ simulation }: { simulation: SimulationState }) {
|
||||
const current = simulation.currentItem;
|
||||
const result = current?.classification;
|
||||
const item = current?.item;
|
||||
const dimensions = item?.dimensionsMm;
|
||||
const isOversizedAndRound = Boolean(result && !result.dimensionsPass && !result.roundnessPass);
|
||||
const lowConfidence = Boolean(item && item.confidence < 0.65);
|
||||
|
||||
return (
|
||||
<section className="panel compact-panel">
|
||||
<section className="panel compact-panel classification-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Classification rules</p>
|
||||
<h2>Decision tree</h2>
|
||||
<h2>Decision proof</h2>
|
||||
</div>
|
||||
<div className="rule-stack">
|
||||
<div className="rule-card">
|
||||
<div><strong>Step 1: Dimensions check</strong><Verdict pass={result?.dimensionsPass} /></div>
|
||||
<p>Allowed: W {DIMENSION_LIMITS.min.width}-{DIMENSION_LIMITS.max.width} mm, D {DIMENSION_LIMITS.min.depth}-{DIMENSION_LIMITS.max.depth} mm, H {DIMENSION_LIMITS.min.height}-{DIMENSION_LIMITS.max.height} mm.</p>
|
||||
<p>Actual: {dimensions ? `${dimensions.width} x ${dimensions.depth} x ${dimensions.height} mm` : 'waiting for item'}</p>
|
||||
</div>
|
||||
<div className="rule-card">
|
||||
<div><strong>Step 2: Roundness check</strong><Verdict pass={result?.roundnessPass} skipped={result ? !result.dimensionsPass : false} /></div>
|
||||
<p>Roundness threshold for D: value >= {DIMENSION_LIMITS.roundnessThreshold}.</p>
|
||||
<p>Actual roundness: {item ? item.roundness.toFixed(2) : 'waiting for item'}</p>
|
||||
</div>
|
||||
<div className="rule-card">
|
||||
<div><strong>Step 3: Category decision</strong><span className={result ? `decision-badge category-${result.category}` : 'decision-badge'}>{result ? result.category : '-'}</span></div>
|
||||
<p>{result ? result.reason : 'Run a scenario to classify the current item.'}</p>
|
||||
{isOversizedAndRound ? <p className="warning-note">Приоритет: C, потому что габариты проверяются первыми.</p> : null}
|
||||
{lowConfidence ? <p className="warning-note">CV confidence низкий, решение подтверждено rule-based fallback.</p> : null}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { SimulationState } from '../domain/types';
|
||||
|
||||
export default function CurrentItemPanel({ simulation }: { simulation: SimulationState }) {
|
||||
const current = simulation.currentItem;
|
||||
const camera = simulation.sensors.camera;
|
||||
const laser = simulation.sensors.laser;
|
||||
|
||||
return (
|
||||
<section className="panel compact-panel">
|
||||
@@ -10,15 +12,27 @@ export default function CurrentItemPanel({ simulation }: { simulation: Simulatio
|
||||
<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>
|
||||
<>
|
||||
<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>Shape</span><strong>{current.item.shape}</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>
|
||||
<div className="pseudo-cv-block">
|
||||
<h3>Pseudo-CV / sensor packet</h3>
|
||||
<div className="kv-grid compact-kv">
|
||||
<span>bbox</span><strong>{camera.bbox ? `${camera.bbox.width} x ${camera.bbox.height} mm` : 'pending'}</strong>
|
||||
<span>confidence</span><strong>{camera.confidence ? `${Math.round(camera.confidence * 100)}%` : 'pending'}</strong>
|
||||
<span>cv latency</span><strong>{camera.cvLatencyMs} ms</strong>
|
||||
<span>detected dimensions</span><strong>{current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} x {current.item.dimensionsMm.height} mm</strong>
|
||||
<span>measured height</span><strong>{laser.measuredHeightMm ?? current.item.dimensionsMm.height} mm</strong>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Start or step the simulation to feed an item into zone A.</p>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import EventLog from './EventLog';
|
||||
import ScenarioPanel from './ScenarioPanel';
|
||||
import SensorPanel from './SensorPanel';
|
||||
import PidPanel from './PidPanel';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import type { Scenario, ScenarioId, SimulationState } from '../domain/types';
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -46,12 +47,13 @@ export default function Dashboard({
|
||||
</section>
|
||||
<aside className="right-rail">
|
||||
<CurrentItemPanel simulation={simulation} />
|
||||
<ClassificationPanel currentItem={simulation.currentItem} />
|
||||
<ClassificationPanel simulation={simulation} />
|
||||
<StateMachinePanel currentState={simulation.machineState} />
|
||||
<SensorPanel simulation={simulation} />
|
||||
</aside>
|
||||
<section className="bottom-row">
|
||||
<MetricsPanel metrics={simulation.metrics} />
|
||||
<TimelinePanel simulation={simulation} />
|
||||
<PidPanel pid={simulation.pid} metrics={simulation.metrics} status={simulation.systemStatus} />
|
||||
<EventLog events={simulation.events} />
|
||||
<ScenarioPanel
|
||||
|
||||
@@ -1,29 +1,63 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { EventLogEntry } from '../domain/types';
|
||||
|
||||
type LogFilter = 'all' | 'sensor' | 'classification' | 'actuator' | 'warning' | 'error' | 'success';
|
||||
|
||||
const filters: LogFilter[] = ['all', 'sensor', 'classification', 'actuator', 'warning', 'error', 'success'];
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return `t+${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function matchesFilter(entry: EventLogEntry, filter: LogFilter): boolean {
|
||||
if (filter === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (filter === 'error' || filter === 'success' || filter === 'warning') {
|
||||
return entry.status === filter;
|
||||
}
|
||||
return entry.type === filter;
|
||||
}
|
||||
|
||||
export default function EventLog({ events }: { events: EventLogEntry[] }) {
|
||||
const [filter, setFilter] = useState<LogFilter>('all');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const visibleEvents = useMemo(() => events.filter((entry) => matchesFilter(entry, filter)), [events, filter]);
|
||||
|
||||
const copyJson = async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(events, null, 2));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel event-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Event log</p>
|
||||
<h2>Cycle trace</h2>
|
||||
<div>
|
||||
<p className="eyebrow">Event log</p>
|
||||
<h2>Cycle trace</h2>
|
||||
</div>
|
||||
<button className="small-button" onClick={() => void copyJson()}>{copied ? 'Copied' : 'Copy JSON'}</button>
|
||||
</div>
|
||||
<div className="log-filters">
|
||||
{filters.map((item) => (
|
||||
<button key={item} className={filter === item ? 'active' : ''} onClick={() => setFilter(item)}>{item}</button>
|
||||
))}
|
||||
</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>
|
||||
<span>time</span><span>item</span><span>state</span><span>type</span><span>command/category</span><span>message</span><span>status</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="empty-log">No events yet. Press Start or Step.</div>
|
||||
) : events.map((entry) => (
|
||||
{visibleEvents.length === 0 ? (
|
||||
<div className="empty-log">No events for this filter.</div>
|
||||
) : visibleEvents.map((entry) => (
|
||||
<div className={`event-row ${entry.status}`} role="row" key={entry.id}>
|
||||
<span>{formatTime(entry.timestampMs)}</span>
|
||||
<span>{entry.itemId ?? '-'}</span>
|
||||
<span>{entry.state ?? '-'}</span>
|
||||
<span>{entry.type}</span>
|
||||
<span>{entry.command ?? entry.category ?? '-'}</span>
|
||||
<span>{entry.message}</span>
|
||||
<span>{entry.category ?? '-'}</span>
|
||||
<span>{entry.status}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export default function Header({ simulation, onStart, onPause, onReset, onStep }: HeaderProps) {
|
||||
const locked = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div>
|
||||
@@ -21,11 +23,12 @@ export default function Header({ simulation, onStart, onPause, onReset, onStep }
|
||||
{simulation.systemStatus}
|
||||
</div>
|
||||
<div className="scenario-label">Scenario: {simulation.scenario.name}</div>
|
||||
<div className="scenario-label">Sim t+{(simulation.simTimeMs / 1000).toFixed(1)}s</div>
|
||||
<div className="header-actions">
|
||||
<button onClick={onStart}>Start</button>
|
||||
<button onClick={onStart} disabled={locked}>Start</button>
|
||||
<button onClick={onPause}>Pause</button>
|
||||
<button onClick={onReset}>Reset</button>
|
||||
<button onClick={onStep}>Step</button>
|
||||
<button onClick={onStep} disabled={locked}>Step state</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -9,6 +9,7 @@ const metricLabels: Array<[keyof Metrics, string, string]> = [
|
||||
['cvLatencyMs', 'CV latency', 'ms'],
|
||||
['actuatorLatencyMs', 'Actuator latency', 'ms'],
|
||||
['queueLength', 'Queue length', 'items'],
|
||||
['queueDelayMs', 'Queue delay', 'ms'],
|
||||
['conveyorSpeedMps', 'Conveyor speed', 'm/s'],
|
||||
['pidTargetSpeedMps', 'PID target', 'm/s'],
|
||||
['pidActualSpeedMps', 'PID actual', 'm/s'],
|
||||
|
||||
@@ -6,37 +6,53 @@ interface PidPanelProps {
|
||||
status: SystemStatus;
|
||||
}
|
||||
|
||||
function pidState(pid: PidState, status: SystemStatus): string {
|
||||
if (status === 'EMERGENCY_STOP') {
|
||||
return 'emergency stop';
|
||||
}
|
||||
if (status === 'FAULT') {
|
||||
return 'stopped by fault';
|
||||
}
|
||||
if (Math.abs(pid.pidError) <= 0.03 && pid.actualSpeedMps > 0) {
|
||||
return 'stable';
|
||||
}
|
||||
return 'stabilizing';
|
||||
}
|
||||
|
||||
function sparkline(history: number[]): string {
|
||||
const values = history.length > 1 ? history : [0, 0];
|
||||
const max = Math.max(...values, 0.5);
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const x = (index / Math.max(values.length - 1, 1)) * 220;
|
||||
const y = 58 - (value / max) * 48;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export default function PidPanel({ pid, status }: PidPanelProps) {
|
||||
const fill = Math.min((pid.actualSpeedMps / Math.max(pid.targetSpeedMps, 0.01)) * 100, 100);
|
||||
const state = pidState(pid, status);
|
||||
|
||||
return (
|
||||
<section className="panel pid-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Conveyor control loop</p>
|
||||
<h2>PID imitation</h2>
|
||||
<h2>{state}</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><span>target speed</span><strong>{pid.targetSpeedMps.toFixed(2)} m/s</strong></div>
|
||||
<div><span>actual speed</span><strong>{pid.actualSpeedMps.toFixed(2)} m/s</strong></div>
|
||||
<div><span>error</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>
|
||||
<div className="speed-track"><div style={{ width: `${fill}%` }} /></div>
|
||||
<svg className="pid-sparkline" viewBox="0 0 220 64" role="img" aria-label="PID speed history">
|
||||
<path d="M0 58 H220" />
|
||||
<polyline points={sparkline(pid.speedHistoryMps)} />
|
||||
</svg>
|
||||
<p className="muted">PID shown as simplified control-loop simulation for conveyor speed stabilization.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioC
|
||||
<section className="panel scenario-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Scenario panel</p>
|
||||
<h2>Test cases</h2>
|
||||
<h2>Jury test cases</h2>
|
||||
</div>
|
||||
<div className="scenario-buttons">
|
||||
{scenarios.map((scenario) => (
|
||||
@@ -22,6 +22,9 @@ export default function ScenarioPanel({ scenarios, activeScenarioId, onScenarioC
|
||||
>
|
||||
<strong>{scenario.name}</strong>
|
||||
<span>{scenario.description}</span>
|
||||
<em>Goal: {scenario.goal}</em>
|
||||
<em>Expected: {scenario.expectedCategorySummary}</em>
|
||||
<em>Shows: {scenario.demonstrates}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,15 @@ const routeColors: Record<Category, string> = {
|
||||
|
||||
function progressForState(state: MachineState, elapsedMs: number): number {
|
||||
const ratios: Partial<Record<MachineState, [number, number, number]>> = {
|
||||
MOVING_TO_CAMERA: [0.08, 0.34, 1200],
|
||||
MOVING_TO_CAMERA: [0.06, 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],
|
||||
ROUTE_TO_B: [0.66, 0.93, 1200],
|
||||
ROUTE_TO_C: [0.66, 0.78, 1200],
|
||||
ROUTE_TO_D: [0.66, 0.78, 1200],
|
||||
RETURN_HOME: [0.93, 0.94, 700],
|
||||
};
|
||||
const segment = ratios[state];
|
||||
if (!segment) {
|
||||
@@ -28,20 +28,42 @@ function progressForState(state: MachineState, elapsedMs: number): number {
|
||||
|
||||
function itemPosition(simulation: SimulationState): { x: number; y: number } {
|
||||
const progress = progressForState(simulation.machineState, simulation.elapsedInStateMs);
|
||||
const baseX = 120 + progress * 760;
|
||||
const beltY = 315;
|
||||
const baseX = 126 + progress * 790;
|
||||
const beltY = 360;
|
||||
|
||||
if (simulation.machineState === 'ROUTE_TO_C') {
|
||||
return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
|
||||
return { x: baseX, y: beltY + Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 };
|
||||
}
|
||||
|
||||
if (simulation.machineState === 'ROUTE_TO_D') {
|
||||
return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 150 };
|
||||
return { x: baseX, y: beltY - Math.min(simulation.elapsedInStateMs / 1200, 1) * 172 };
|
||||
}
|
||||
|
||||
return { x: baseX, y: beltY };
|
||||
}
|
||||
|
||||
function DimensionLine({ x1, y1, x2, y2, label }: { x1: number; y1: number; x2: number; y2: number; label: string }) {
|
||||
const labelX = (x1 + x2) / 2;
|
||||
const labelY = (y1 + y2) / 2;
|
||||
return (
|
||||
<g className="dimension-line">
|
||||
<line x1={x1} y1={y1} x2={x2} y2={y2} />
|
||||
<circle cx={x1} cy={y1} r="3" />
|
||||
<circle cx={x2} cy={y2} r="3" />
|
||||
<text x={labelX} y={labelY - 8}>{label}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendItem({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<g className="legend-item">
|
||||
<rect width="10" height="10" fill={color} rx="2" />
|
||||
<text x="16" y="10">{label}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SorterScene({ simulation }: { simulation: SimulationState }) {
|
||||
const current = simulation.currentItem;
|
||||
const category = current?.classification.category;
|
||||
@@ -49,114 +71,144 @@ export default function SorterScene({ simulation }: { simulation: SimulationStat
|
||||
const position = itemPosition(simulation);
|
||||
const detecting = simulation.machineState === 'DETECTING';
|
||||
const routeVisible = simulation.machineState.startsWith('ROUTE_TO_');
|
||||
const stopped = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
const itemWidth = current ? Math.max(24, Math.min(74, current.item.dimensionsMm.width / 6)) : 56;
|
||||
const itemHeight = current ? Math.max(18, Math.min(58, current.item.dimensionsMm.depth / 5.5)) : 48;
|
||||
|
||||
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>
|
||||
<p className="eyebrow">Work zone 6000 x 10000 mm / conveyor 500 mm</p>
|
||||
<h2>Engineering layout, sensors and routing commands</h2>
|
||||
</div>
|
||||
<div className="machine-state-chip">{simulation.machineState}</div>
|
||||
<div className={`machine-state-chip ${stopped ? 'fault-chip' : ''}`}>{simulation.machineState}</div>
|
||||
</div>
|
||||
<svg viewBox="0 0 1000 620" role="img" aria-label="Sorter simulation scene" className="sorter-svg">
|
||||
<svg viewBox="0 0 1120 720" 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 id="gridMinor" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#12283b" 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>
|
||||
<pattern id="gridMajor" width="120" height="120" patternUnits="userSpaceOnUse">
|
||||
<rect width="120" height="120" fill="url(#gridMinor)" />
|
||||
<path d="M 120 0 L 0 0 0 120" fill="none" stroke="#244863" strokeWidth="1.4" />
|
||||
</pattern>
|
||||
{(['B', 'C', 'D'] as Category[]).map((route) => (
|
||||
<marker key={route} id={`arrow${route}`} markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill={routeColors[route]} />
|
||||
</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" />
|
||||
<rect x="24" y="26" width="1072" height="632" rx="12" fill="#07111d" stroke="#244863" />
|
||||
<rect x="64" y="82" width="928" height="520" fill="url(#gridMajor)" opacity="0.9" />
|
||||
<text x="78" y="74" className="scale-label left-label">Scaled plan: 6000 mm x 10000 mm work cell</text>
|
||||
|
||||
<DimensionLine x1={64} y1={626} x2={992} y2={626} label="6000 mm work zone width" />
|
||||
<DimensionLine x1={1024} y1={82} x2={1024} y2={602} label="10000 mm work zone length" />
|
||||
<DimensionLine x1={92} y1={314} x2={92} y2={406} label="500 mm conveyor" />
|
||||
|
||||
<g className="zone zone-a">
|
||||
<rect x="70" y="246" width="120" height="138" rx="14" />
|
||||
<text x="130" y="238">A feed</text>
|
||||
<rect x="88" y="292" width="128" height="136" rx="8" />
|
||||
<text x="152" y="282">A feed zone</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>
|
||||
<rect x="842" y="292" width="136" height="136" rx="8" />
|
||||
<text x="910" y="282">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>
|
||||
<rect x="642" y="112" width="210" height="124" rx="8" />
|
||||
<text x="747" y="102">D roll-cage 1200 x 800 x 800 mm</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>
|
||||
<rect x="642" y="486" width="210" height="124" rx="8" />
|
||||
<text x="747" y="632">C roll-cage 1200 x 800 x 800 mm</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" />
|
||||
<rect className={stopped ? 'conveyor stopped' : 'conveyor'} x="106" y="314" width="850" height="92" rx="6" />
|
||||
<line x1="126" y1="360" x2="936" y2="360" className="belt-center" />
|
||||
{Array.from({ length: 18 }).map((_, index) => (
|
||||
<line key={index} x1={132 + index * 44} y1="324" x2={158 + index * 44} y2="396" className="roller-line" />
|
||||
))}
|
||||
|
||||
<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>
|
||||
<rect x="350" y="218" width="76" height="50" rx="6" />
|
||||
<line x1="388" y1="268" x2="388" y2="314" />
|
||||
<text x="388" y="208">Camera / bbox</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>
|
||||
<rect x="474" y="218" width="76" height="50" rx="6" />
|
||||
<line x1="512" y1="268" x2="512" y2="314" />
|
||||
<text x="512" y="208">Laser height</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>
|
||||
<circle cx="646" cy="243" r="28" />
|
||||
<line x1="646" y1="271" x2="646" y2="314" />
|
||||
<text x="646" y="208">Ultrasonic gate</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>
|
||||
<line x1="708" y1="296" x2="708" y2="424" />
|
||||
<text x="746" y="300">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>
|
||||
<rect x="625" y="424" width="174" height="34" rx="6" />
|
||||
<text x="712" y="476">Pusher C command</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>
|
||||
<rect x="625" y="262" width="174" height="34" rx="6" />
|
||||
<text x="712" y="256">Pusher D command</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}
|
||||
<line x1="706" y1="360" x2="928" y2="360" className="route-guide route-b" markerEnd="url(#arrowB)" />
|
||||
<line x1="706" y1="376" x2="748" y2="548" className="route-guide route-c" markerEnd="url(#arrowC)" />
|
||||
<line x1="706" y1="344" x2="748" y2="174" className="route-guide route-d" markerEnd="url(#arrowD)" />
|
||||
{routeVisible && category ? (
|
||||
<g className="route-command">
|
||||
<rect x="788" y="326" width="156" height="34" rx="8" fill={routeColors[category]} />
|
||||
<text x="866" y="348">{simulation.machineState}</text>
|
||||
</g>
|
||||
) : 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}
|
||||
x={position.x - itemWidth / 2}
|
||||
y={position.y - itemHeight / 2}
|
||||
width={itemWidth}
|
||||
height={itemHeight}
|
||||
rx={current.item.shape.includes('round') || current.item.shape.includes('cylinder') ? Math.min(itemWidth, itemHeight) / 2 : 5}
|
||||
fill={color}
|
||||
opacity="0.92"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.5"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
<text x={position.x} y={position.y + 44} className="item-label">{current.item.id}</text>
|
||||
<text x={position.x} y={position.y + itemHeight / 2 + 18} 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" />
|
||||
<g className="bbox">
|
||||
<rect x={position.x - itemWidth / 2 - 10} y={position.y - itemHeight / 2 - 10} width={itemWidth + 20} height={itemHeight + 20} />
|
||||
<text x={position.x} y={position.y - itemHeight / 2 - 18}>bbox {current.item.dimensionsMm.width} x {current.item.dimensionsMm.depth} mm</text>
|
||||
</g>
|
||||
) : 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>
|
||||
{stopped ? (
|
||||
<g className="fault-overlay">
|
||||
<rect x="610" y="286" width="210" height="148" rx="10" />
|
||||
<text x="715" y="350">{simulation.machineState}</text>
|
||||
<text x="715" y="376">Conveyor stopped, reset required</text>
|
||||
</g>
|
||||
) : null}
|
||||
|
||||
<g className="scene-legend" transform="translate(78 664)">
|
||||
<LegendItem color="#4ade80" label="B main sorter" />
|
||||
<g transform="translate(140 0)"><LegendItem color="#f59e0b" label="C oversize" /></g>
|
||||
<g transform="translate(270 0)"><LegendItem color="#c084fc" label="D shape / repack" /></g>
|
||||
<g transform="translate(430 0)"><LegendItem color="#38bdf8" label="camera / laser / ultrasonic active" /></g>
|
||||
<g transform="translate(700 0)"><LegendItem color="#fb3d4e" label="stop-gate / fault" /></g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
39
src/components/TimelinePanel.tsx
Normal file
39
src/components/TimelinePanel.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { buildCycleTimeline } from '../domain/simulation';
|
||||
import type { SimulationState } from '../domain/types';
|
||||
|
||||
function formatMs(ms?: number): string {
|
||||
return ms === undefined ? '-' : `t+${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function TimelinePanel({ simulation }: { simulation: SimulationState }) {
|
||||
const timeline = buildCycleTimeline(simulation);
|
||||
const current = simulation.currentItem;
|
||||
const totalCycleTime = current ? simulation.simTimeMs - current.startedAtMs : 0;
|
||||
|
||||
return (
|
||||
<section className="panel timeline-panel">
|
||||
<div className="panel-heading">
|
||||
<p className="eyebrow">Cycle timeline</p>
|
||||
<h2>{current ? current.item.id : 'No active cycle'}</h2>
|
||||
</div>
|
||||
<div className="timeline-summary">
|
||||
<div><span>total cycle</span><strong>{totalCycleTime} ms</strong></div>
|
||||
<div><span>cv latency</span><strong>{simulation.metrics.cvLatencyMs} ms</strong></div>
|
||||
<div><span>actuator latency</span><strong>{simulation.metrics.actuatorLatencyMs} ms</strong></div>
|
||||
<div><span>conveyor</span><strong>{simulation.metrics.conveyorSpeedMps.toFixed(2)} m/s</strong></div>
|
||||
<div><span>queue delay</span><strong>{simulation.metrics.queueDelayMs} ms</strong></div>
|
||||
</div>
|
||||
<div className="timeline-list">
|
||||
{timeline.map((entry) => (
|
||||
<div className={`timeline-row ${entry.status}`} key={entry.state}>
|
||||
<span className="timeline-dot" />
|
||||
<strong>{entry.state}</strong>
|
||||
<span>{formatMs(entry.startedAtMs)}</span>
|
||||
<span>{entry.durationMs} ms</span>
|
||||
<em>{entry.status}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
31
src/data/scenarios.test.ts
Normal file
31
src/data/scenarios.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SCENARIOS } from './scenarios';
|
||||
import type { ScenarioId } from '../domain/types';
|
||||
|
||||
const expectedIds: ScenarioId[] = [
|
||||
'normal_flow',
|
||||
'oversized_item',
|
||||
'round_object',
|
||||
'boundary_dimensions',
|
||||
'close_items',
|
||||
'low_confidence',
|
||||
'jam',
|
||||
'emergency_stop',
|
||||
];
|
||||
|
||||
describe('scenarios', () => {
|
||||
it('contains every expected scenario id', () => {
|
||||
const ids = SCENARIOS.map((scenario) => scenario.id);
|
||||
|
||||
expect(ids).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('contains items and demo metadata for every scenario', () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
expect(scenario.items.length).toBeGreaterThan(0);
|
||||
expect(scenario.goal.length).toBeGreaterThan(0);
|
||||
expect(scenario.expectedCategorySummary.length).toBeGreaterThan(0);
|
||||
expect(scenario.demonstrates.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,36 +6,54 @@ export const SCENARIOS: Scenario[] = [
|
||||
id: 'normal_flow',
|
||||
name: 'Normal flow',
|
||||
description: 'Обычный поток из нескольких товаров B/C/D с полным циклом сортировки.',
|
||||
goal: 'Показать базовую последовательность sensor -> gate -> classifier -> actuator.',
|
||||
expectedCategorySummary: 'Mixed: B, C, D',
|
||||
demonstrates: 'Стабильный поток, корректную маршрутизацию и возврат исполнительных механизмов домой.',
|
||||
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.',
|
||||
goal: 'Доказать приоритет проверки габаритов.',
|
||||
expectedCategorySummary: 'C for every item',
|
||||
demonstrates: 'Негабаритный товар фиксируется stop-gate и уводится pusher C в roll-cage C.',
|
||||
items: ['SKU-004', 'SKU-005'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'round_object',
|
||||
name: 'Round object',
|
||||
description: 'Габариты проходят, но roundness >= 0.8, маршрут в D.',
|
||||
goal: 'Показать проверку круглого сечения после габаритов.',
|
||||
expectedCategorySummary: 'D for every item',
|
||||
demonstrates: 'Rule-based shape issue routing без реального ML на MVP-этапе.',
|
||||
items: ['SKU-006', 'SKU-007', 'SKU-008'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'boundary_dimensions',
|
||||
name: 'Boundary dimensions',
|
||||
description: 'Товары около min/max границ показывают устойчивость правил.',
|
||||
goal: 'Проверить строгие границы min/max.',
|
||||
expectedCategorySummary: 'Boundary box -> B, Pen -> C',
|
||||
demonstrates: '450 x 320 x 320 проходит, width 9 мм не проходит min width.',
|
||||
items: ['SKU-010', 'SKU-009', 'SKU-002'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'close_items',
|
||||
name: 'Close items',
|
||||
description: 'Два товара близко друг к другу: warning queue/spacing и последовательная обработка.',
|
||||
goal: 'Показать устойчивость очереди без усложнения физики.',
|
||||
expectedCategorySummary: 'Sequential B, B, D',
|
||||
demonstrates: 'Spacing warning, queue length и обработку товаров по одному циклу.',
|
||||
items: ['SKU-001', 'SKU-002', 'SKU-006'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'low_confidence',
|
||||
name: 'Low confidence',
|
||||
description: 'CV confidence ниже 0.65, система предупреждает и принимает rule-based решение.',
|
||||
goal: 'Показать fallback при низкой уверенности pseudo-CV.',
|
||||
expectedCategorySummary: 'Rule-based B and D despite low CV confidence',
|
||||
demonstrates: 'Низкая confidence не блокирует решение, так как финальная логика основана на правилах.',
|
||||
items: [
|
||||
{ ...getItem('SKU-003'), id: 'SKU-003-LC', confidence: 0.58 },
|
||||
{ ...getItem('SKU-006'), id: 'SKU-006-LC', confidence: 0.61 },
|
||||
@@ -45,12 +63,18 @@ export const SCENARIOS: Scenario[] = [
|
||||
id: 'jam',
|
||||
name: 'Jam at gate',
|
||||
description: 'Застревание у stop-gate переводит систему в FAULT и останавливает конвейер.',
|
||||
goal: 'Показать fail-safe состояние при застревании.',
|
||||
expectedCategorySummary: 'FAULT before route completion',
|
||||
demonstrates: 'Conveyor speed падает к 0, state фиксируется в FAULT, требуется Reset.',
|
||||
items: ['SKU-004'].map(getItem),
|
||||
},
|
||||
{
|
||||
id: 'emergency_stop',
|
||||
name: 'Emergency stop',
|
||||
description: 'Аварийная остановка переводит систему в EMERGENCY_STOP, движение остановлено.',
|
||||
goal: 'Показать ручную/аварийную остановку всей линии.',
|
||||
expectedCategorySummary: 'EMERGENCY_STOP before route completion',
|
||||
demonstrates: 'Все движения останавливаются, PID target становится 0, требуется Reset.',
|
||||
items: ['SKU-001', 'SKU-006'].map(getItem),
|
||||
},
|
||||
];
|
||||
|
||||
40
src/domain/classifier.test.ts
Normal file
40
src/domain/classifier.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyItem } from './classifier';
|
||||
import { getItem } from '../data/items';
|
||||
import type { Item } from './types';
|
||||
|
||||
describe('classifyItem', () => {
|
||||
it('routes normal box to B', () => {
|
||||
expect(classifyItem(getItem('SKU-001')).category).toBe('B');
|
||||
});
|
||||
|
||||
it('routes oversized box to C', () => {
|
||||
expect(classifyItem(getItem('SKU-004')).category).toBe('C');
|
||||
});
|
||||
|
||||
it('routes round plate to D', () => {
|
||||
expect(classifyItem(getItem('SKU-006')).category).toBe('D');
|
||||
});
|
||||
|
||||
it('routes pen with width below 10 mm to C', () => {
|
||||
expect(classifyItem(getItem('SKU-009')).category).toBe('C');
|
||||
});
|
||||
|
||||
it('keeps C priority when item is oversized and round', () => {
|
||||
const item: Item = {
|
||||
...getItem('SKU-005'),
|
||||
id: 'TEST-OVERSIZED-ROUND',
|
||||
roundness: 0.95,
|
||||
};
|
||||
|
||||
const result = classifyItem(item);
|
||||
|
||||
expect(result.category).toBe('C');
|
||||
expect(result.dimensionsPass).toBe(false);
|
||||
expect(result.roundnessPass).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts boundary item 450x320x320 as B', () => {
|
||||
expect(classifyItem(getItem('SKU-010')).category).toBe('B');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export function createMetrics(): Metrics {
|
||||
cvLatencyMs: 0,
|
||||
actuatorLatencyMs: 0,
|
||||
queueLength: 0,
|
||||
queueDelayMs: 0,
|
||||
conveyorSpeedMps: 0,
|
||||
pidTargetSpeedMps: 0.42,
|
||||
pidActualSpeedMps: 0,
|
||||
|
||||
31
src/domain/pid.test.ts
Normal file
31
src/domain/pid.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPidState, updatePid } from './pid';
|
||||
|
||||
describe('PID conveyor speed imitation', () => {
|
||||
it('moves actual speed toward target in normal mode', () => {
|
||||
let pid = createPidState(0.42);
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
pid = updatePid(pid, 0.42, 250);
|
||||
}
|
||||
|
||||
expect(pid.actualSpeedMps).toBeGreaterThan(0.3);
|
||||
expect(Math.abs(pid.pidError)).toBeLessThan(0.15);
|
||||
});
|
||||
|
||||
it('decays actual speed toward zero when target is stopped', () => {
|
||||
let pid = createPidState(0.42);
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
pid = updatePid(pid, 0.42, 250);
|
||||
}
|
||||
const runningSpeed = pid.actualSpeedMps;
|
||||
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
pid = updatePid(pid, 0, 250);
|
||||
}
|
||||
|
||||
expect(pid.actualSpeedMps).toBeLessThan(runningSpeed);
|
||||
expect(pid.actualSpeedMps).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { PidState } from './types';
|
||||
|
||||
const PID_HISTORY_LIMIT = 40;
|
||||
|
||||
export function createPidState(targetSpeedMps = 0.42): PidState {
|
||||
return {
|
||||
targetSpeedMps,
|
||||
actualSpeedMps: 0,
|
||||
pidError: targetSpeedMps,
|
||||
correction: 0,
|
||||
speedHistoryMps: [0],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,11 +17,14 @@ export function updatePid(state: PidState, targetSpeedMps: number, deltaMs: numb
|
||||
const pidError = targetSpeedMps - state.actualSpeedMps;
|
||||
const correction = pidError * 0.42;
|
||||
const nextActual = Math.max(0, state.actualSpeedMps + correction * dt);
|
||||
const actualSpeedMps = Number(nextActual.toFixed(3));
|
||||
const speedHistoryMps = [...state.speedHistoryMps, actualSpeedMps].slice(-PID_HISTORY_LIMIT);
|
||||
|
||||
return {
|
||||
targetSpeedMps,
|
||||
actualSpeedMps: Number(nextActual.toFixed(3)),
|
||||
actualSpeedMps,
|
||||
pidError: Number(pidError.toFixed(3)),
|
||||
correction: Number(correction.toFixed(3)),
|
||||
speedHistoryMps,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createMetrics, recordCycle } from './metrics';
|
||||
import { createPidState, updatePid } from './pid';
|
||||
import type {
|
||||
Category,
|
||||
CycleTimelineEntry,
|
||||
EventLogEntry,
|
||||
MachineState,
|
||||
Scenario,
|
||||
@@ -10,7 +11,7 @@ import type {
|
||||
SystemStatus,
|
||||
} from './types';
|
||||
|
||||
const STATE_DURATIONS_MS: Partial<Record<MachineState, number>> = {
|
||||
export const STATE_DURATIONS_MS: Record<Exclude<MachineState, 'IDLE' | 'FAULT' | 'EMERGENCY_STOP'>, number> = {
|
||||
MOVING_TO_CAMERA: 1200,
|
||||
DETECTING: 900,
|
||||
MOVING_TO_GATE: 1300,
|
||||
@@ -22,6 +23,18 @@ const STATE_DURATIONS_MS: Partial<Record<MachineState, number>> = {
|
||||
RETURN_HOME: 700,
|
||||
};
|
||||
|
||||
export const CYCLE_STATE_ORDER: MachineState[] = [
|
||||
'MOVING_TO_CAMERA',
|
||||
'DETECTING',
|
||||
'MOVING_TO_GATE',
|
||||
'WAITING_AT_GATE',
|
||||
'CLASSIFYING',
|
||||
'ROUTE_TO_B',
|
||||
'ROUTE_TO_C',
|
||||
'ROUTE_TO_D',
|
||||
'RETURN_HOME',
|
||||
];
|
||||
|
||||
const ROUTE_STATE_BY_CATEGORY: Record<Category, MachineState> = {
|
||||
B: 'ROUTE_TO_B',
|
||||
C: 'ROUTE_TO_C',
|
||||
@@ -30,7 +43,9 @@ const ROUTE_STATE_BY_CATEGORY: Record<Category, MachineState> = {
|
||||
|
||||
let eventCounter = 0;
|
||||
|
||||
function event(timestampMs: number, entry: Omit<EventLogEntry, 'id' | 'timestampMs'>): EventLogEntry {
|
||||
type EventInput = Omit<EventLogEntry, 'id' | 'timestampMs'>;
|
||||
|
||||
function event(timestampMs: number, entry: EventInput): EventLogEntry {
|
||||
eventCounter += 1;
|
||||
return {
|
||||
id: `evt-${eventCounter}`,
|
||||
@@ -39,10 +54,10 @@ function event(timestampMs: number, entry: Omit<EventLogEntry, 'id' | 'timestamp
|
||||
};
|
||||
}
|
||||
|
||||
function appendEvent(state: SimulationState, entry: Omit<EventLogEntry, 'id' | 'timestampMs'>): SimulationState {
|
||||
function appendEvent(state: SimulationState, entry: EventInput): SimulationState {
|
||||
return {
|
||||
...state,
|
||||
events: [event(state.simTimeMs, entry), ...state.events].slice(0, 80),
|
||||
events: [event(state.simTimeMs, { state: state.machineState, ...entry }), ...state.events].slice(0, 120),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,8 +78,10 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati
|
||||
next = appendEvent(next, {
|
||||
itemId: item.id,
|
||||
type: 'sensor',
|
||||
message: `Camera captured bbox, confidence ${Math.round(item.confidence * 100)}%`,
|
||||
message: `Camera bbox ${item.dimensionsMm.width}x${item.dimensionsMm.depth} mm, confidence ${Math.round(item.confidence * 100)}%`,
|
||||
category: state.currentItem?.classification.category,
|
||||
command: 'CAMERA_CAPTURE',
|
||||
state: nextState,
|
||||
status: item.confidence < 0.65 ? 'warning' : 'info',
|
||||
});
|
||||
}
|
||||
@@ -74,6 +91,8 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati
|
||||
itemId: item.id,
|
||||
type: 'actuator',
|
||||
message: 'Stop-gate closed, item fixed before classification',
|
||||
command: 'GATE_CLOSE',
|
||||
state: nextState,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
@@ -84,6 +103,8 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati
|
||||
type: 'classification',
|
||||
message: `${state.currentItem.classification.label}: ${state.currentItem.classification.reason}`,
|
||||
category: state.currentItem.classification.category,
|
||||
command: 'CLASSIFY_RULE_BASED',
|
||||
state: nextState,
|
||||
status: state.currentItem.classification.warnings.length > 0 ? 'warning' : 'success',
|
||||
});
|
||||
}
|
||||
@@ -94,6 +115,8 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati
|
||||
type: 'routing',
|
||||
message: `Route selected: ${state.currentItem.classification.category}`,
|
||||
category: state.currentItem.classification.category,
|
||||
command: nextState,
|
||||
state: nextState,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
@@ -104,7 +127,7 @@ function transitionTo(state: SimulationState, nextState: MachineState): Simulati
|
||||
function startNextItem(state: SimulationState): SimulationState {
|
||||
const item = state.scenario.items[state.itemIndex];
|
||||
if (!item) {
|
||||
return { ...state, machineState: 'IDLE', running: false, systemStatus: 'PAUSED' };
|
||||
return { ...state, machineState: 'IDLE', running: false, systemStatus: 'PAUSED', currentItem: undefined };
|
||||
}
|
||||
|
||||
const classification = classifyItem(item);
|
||||
@@ -118,6 +141,7 @@ function startNextItem(state: SimulationState): SimulationState {
|
||||
},
|
||||
machineState: 'MOVING_TO_CAMERA',
|
||||
elapsedInStateMs: 0,
|
||||
activeRoute: undefined,
|
||||
itemIndex: state.itemIndex + 1,
|
||||
};
|
||||
|
||||
@@ -125,6 +149,8 @@ function startNextItem(state: SimulationState): SimulationState {
|
||||
itemId: item.id,
|
||||
type: 'system',
|
||||
message: `Item entered zone A: ${item.name}`,
|
||||
command: 'FEED_ITEM',
|
||||
state: 'MOVING_TO_CAMERA',
|
||||
status: 'info',
|
||||
});
|
||||
|
||||
@@ -133,6 +159,8 @@ function startNextItem(state: SimulationState): SimulationState {
|
||||
itemId: item.id,
|
||||
type: 'warning',
|
||||
message: 'Queue/spacing warning: next item detected too close, processing sequentially',
|
||||
command: 'QUEUE_HOLD_NEXT_ITEM',
|
||||
state: 'MOVING_TO_CAMERA',
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
@@ -143,6 +171,8 @@ function startNextItem(state: SimulationState): SimulationState {
|
||||
type: 'warning',
|
||||
message: warning,
|
||||
category: classification.category,
|
||||
command: 'RULE_BASED_FALLBACK',
|
||||
state: 'MOVING_TO_CAMERA',
|
||||
status: 'warning',
|
||||
});
|
||||
}
|
||||
@@ -162,6 +192,10 @@ function completeCurrentItem(state: SimulationState): SimulationState {
|
||||
let next = appendEvent(
|
||||
{
|
||||
...state,
|
||||
currentItem: {
|
||||
...state.currentItem,
|
||||
cycleTimeMs,
|
||||
},
|
||||
metrics,
|
||||
},
|
||||
{
|
||||
@@ -169,13 +203,13 @@ function completeCurrentItem(state: SimulationState): SimulationState {
|
||||
type: 'routing',
|
||||
message: `Cycle completed in ${(cycleTimeMs / 1000).toFixed(1)}s`,
|
||||
category: state.currentItem.classification.category,
|
||||
command: 'CYCLE_COMPLETE',
|
||||
status: success ? 'success' : 'error',
|
||||
},
|
||||
);
|
||||
|
||||
next = {
|
||||
...next,
|
||||
currentItem: undefined,
|
||||
activeRoute: undefined,
|
||||
};
|
||||
|
||||
@@ -315,6 +349,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte
|
||||
itemId: next.currentItem?.item.id,
|
||||
type: 'fault',
|
||||
message: 'Emergency stop pressed, all movement stopped',
|
||||
command: 'EMERGENCY_STOP',
|
||||
status: 'error',
|
||||
});
|
||||
next = { ...next, machineState: 'EMERGENCY_STOP', running: false, systemStatus: 'EMERGENCY_STOP' };
|
||||
@@ -327,6 +362,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte
|
||||
type: 'fault',
|
||||
message: 'Jam detected at stop-gate, conveyor stopped',
|
||||
category: next.currentItem?.classification.category,
|
||||
command: 'CONVEYOR_STOP_FAULT',
|
||||
status: 'error',
|
||||
});
|
||||
next = { ...next, machineState: 'FAULT', running: false, systemStatus: 'FAULT' };
|
||||
@@ -350,7 +386,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte
|
||||
next = startNextItem(next);
|
||||
}
|
||||
|
||||
const duration = STATE_DURATIONS_MS[next.machineState];
|
||||
const duration = next.machineState in STATE_DURATIONS_MS ? STATE_DURATIONS_MS[next.machineState as keyof typeof STATE_DURATIONS_MS] : undefined;
|
||||
if (duration && next.elapsedInStateMs >= duration) {
|
||||
switch (next.machineState) {
|
||||
case 'MOVING_TO_CAMERA':
|
||||
@@ -376,7 +412,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte
|
||||
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 };
|
||||
next = next.itemIndex < next.scenario.items.length ? startNextItem(next) : { ...next, currentItem: undefined, machineState: 'IDLE', running: false, systemStatus: 'PAUSED', elapsedInStateMs: 0 };
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -388,6 +424,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte
|
||||
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;
|
||||
const queueDelayMs = next.scenario.id === 'close_items' && queueLength > 1 ? 450 : 0;
|
||||
|
||||
next = {
|
||||
...next,
|
||||
@@ -398,6 +435,7 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte
|
||||
cvLatencyMs,
|
||||
actuatorLatencyMs,
|
||||
queueLength,
|
||||
queueDelayMs,
|
||||
conveyorSpeedMps: pid.actualSpeedMps,
|
||||
pidTargetSpeedMps: pid.targetSpeedMps,
|
||||
pidActualSpeedMps: pid.actualSpeedMps,
|
||||
@@ -406,3 +444,57 @@ export function stepSimulation(state: SimulationState, deltaMs: number, forceSte
|
||||
|
||||
return deriveSensorsAndActuators(next);
|
||||
}
|
||||
|
||||
export function stepSimulationToNextState(state: SimulationState): SimulationState {
|
||||
if (state.machineState === 'FAULT' || state.machineState === 'EMERGENCY_STOP') {
|
||||
return state;
|
||||
}
|
||||
|
||||
const runningState = setRunning({ ...state, running: true, systemStatus: 'RUNNING' }, true);
|
||||
let deltaMs = 1;
|
||||
|
||||
if (runningState.scenario.id === 'emergency_stop') {
|
||||
deltaMs = Math.max(1, 1800 - runningState.simTimeMs);
|
||||
} else if (runningState.scenario.id === 'jam' && runningState.machineState === 'WAITING_AT_GATE') {
|
||||
deltaMs = Math.max(1, 700 - runningState.elapsedInStateMs);
|
||||
} else if (runningState.machineState in STATE_DURATIONS_MS) {
|
||||
const duration = STATE_DURATIONS_MS[runningState.machineState as keyof typeof STATE_DURATIONS_MS];
|
||||
deltaMs = Math.max(1, duration - runningState.elapsedInStateMs);
|
||||
}
|
||||
|
||||
const stepped = stepSimulation(runningState, deltaMs, true);
|
||||
return setRunning(stepped, false);
|
||||
}
|
||||
|
||||
export function buildCycleTimeline(state: SimulationState): CycleTimelineEntry[] {
|
||||
const current = state.currentItem;
|
||||
const activeRoute = current ? ROUTE_STATE_BY_CATEGORY[current.classification.category] : undefined;
|
||||
let cursor = current?.startedAtMs ?? state.simTimeMs;
|
||||
|
||||
return CYCLE_STATE_ORDER.map((machineState) => {
|
||||
const isRouteState = machineState.startsWith('ROUTE_TO_');
|
||||
const skipped = isRouteState && machineState !== activeRoute;
|
||||
const durationMs = STATE_DURATIONS_MS[machineState as keyof typeof STATE_DURATIONS_MS] ?? 0;
|
||||
const startedAtMs = current && !skipped ? cursor : undefined;
|
||||
let status: CycleTimelineEntry['status'] = current ? 'pending' : 'pending';
|
||||
|
||||
if (skipped) {
|
||||
status = 'skipped';
|
||||
} else if (current && machineState === state.machineState) {
|
||||
status = 'active';
|
||||
} else if (current && startedAtMs !== undefined && state.simTimeMs >= startedAtMs + durationMs) {
|
||||
status = 'done';
|
||||
}
|
||||
|
||||
if (!skipped) {
|
||||
cursor += durationMs;
|
||||
}
|
||||
|
||||
return {
|
||||
state: machineState,
|
||||
startedAtMs,
|
||||
durationMs,
|
||||
status,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ 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 type TimelineStatus = 'done' | 'active' | 'pending' | 'skipped';
|
||||
|
||||
export interface DimensionsMm {
|
||||
width: number;
|
||||
@@ -65,6 +66,9 @@ export interface Scenario {
|
||||
id: ScenarioId;
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
expectedCategorySummary: string;
|
||||
demonstrates: string;
|
||||
items: Item[];
|
||||
initialStatus?: SystemStatus;
|
||||
}
|
||||
@@ -76,6 +80,8 @@ export interface EventLogEntry {
|
||||
type: EventType;
|
||||
message: string;
|
||||
category?: Category;
|
||||
command?: string;
|
||||
state?: MachineState;
|
||||
status: EventStatus;
|
||||
}
|
||||
|
||||
@@ -88,6 +94,7 @@ export interface Metrics {
|
||||
cvLatencyMs: number;
|
||||
actuatorLatencyMs: number;
|
||||
queueLength: number;
|
||||
queueDelayMs: number;
|
||||
conveyorSpeedMps: number;
|
||||
pidTargetSpeedMps: number;
|
||||
pidActualSpeedMps: number;
|
||||
@@ -141,6 +148,14 @@ export interface PidState {
|
||||
actualSpeedMps: number;
|
||||
pidError: number;
|
||||
correction: number;
|
||||
speedHistoryMps: number[];
|
||||
}
|
||||
|
||||
export interface CycleTimelineEntry {
|
||||
state: MachineState;
|
||||
startedAtMs?: number;
|
||||
durationMs: number;
|
||||
status: TimelineStatus;
|
||||
}
|
||||
|
||||
export interface SimulatedItem {
|
||||
|
||||
334
src/styles.css
334
src/styles.css
@@ -518,3 +518,337 @@ h2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sorter-svg {
|
||||
height: 660px;
|
||||
}
|
||||
|
||||
.panel-scene {
|
||||
min-height: 746px;
|
||||
}
|
||||
|
||||
.left-label {
|
||||
text-anchor: start;
|
||||
}
|
||||
|
||||
.conveyor {
|
||||
fill: #101f30;
|
||||
stroke: #4b647d;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.conveyor.stopped {
|
||||
fill: #2a1219;
|
||||
stroke: var(--red);
|
||||
filter: drop-shadow(0 0 12px rgba(251, 61, 78, 0.35));
|
||||
}
|
||||
|
||||
.belt-center {
|
||||
stroke: #7b8fa5;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 18 12;
|
||||
}
|
||||
|
||||
.roller-line {
|
||||
stroke: #243d55;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.dimension-line line {
|
||||
stroke: #6c849d;
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 5 5;
|
||||
}
|
||||
|
||||
.dimension-line circle {
|
||||
fill: #6c849d;
|
||||
}
|
||||
|
||||
.dimension-line text,
|
||||
.legend-item text,
|
||||
.route-command text {
|
||||
fill: #d9e8f7;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.scene-legend text {
|
||||
text-anchor: start;
|
||||
fill: #b8c8da;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.route-guide {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 10 9;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.route-b { stroke: var(--green); }
|
||||
.route-c { stroke: var(--orange); }
|
||||
.route-d { stroke: var(--purple); }
|
||||
|
||||
.route-command text {
|
||||
fill: #05101b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bbox rect {
|
||||
fill: none;
|
||||
stroke: var(--cyan);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 5;
|
||||
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.7));
|
||||
}
|
||||
|
||||
.bbox text {
|
||||
fill: var(--cyan);
|
||||
font-size: 12px;
|
||||
text-anchor: middle;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.fault-chip {
|
||||
border-color: var(--red);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.fault-overlay rect {
|
||||
fill: rgba(54, 12, 22, 0.88);
|
||||
stroke: var(--red);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.fault-overlay text {
|
||||
fill: #fecdd3;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.pseudo-cv-block {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #24445f;
|
||||
border-radius: 14px;
|
||||
background: #071423;
|
||||
}
|
||||
|
||||
.pseudo-cv-block h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--cyan);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.compact-kv {
|
||||
grid-template-columns: 116px 1fr;
|
||||
}
|
||||
|
||||
.classification-panel .panel-heading {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rule-stack {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
padding: 10px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 13px;
|
||||
background: #081422;
|
||||
}
|
||||
|
||||
.rule-card div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rule-card strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rule-card p {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.verdict,
|
||||
.decision-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #334d66;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.verdict.pass { color: var(--green); border-color: var(--green); }
|
||||
.verdict.fail { color: var(--orange); border-color: var(--orange); }
|
||||
.verdict.pending,
|
||||
.verdict.skipped { color: var(--muted); }
|
||||
.warning-note {
|
||||
margin-top: 6px;
|
||||
color: #facc15 !important;
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
grid-template-columns: 1fr 1.15fr 0.82fr 1.35fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.timeline-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.timeline-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timeline-summary div {
|
||||
padding: 8px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 10px;
|
||||
background: #081422;
|
||||
}
|
||||
|
||||
.timeline-summary span,
|
||||
.timeline-summary strong {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.timeline-summary span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(130px, 1fr) 60px 62px 58px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #203950;
|
||||
border-radius: 10px;
|
||||
color: #b8c8da;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.timeline-row strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.timeline-row em {
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #31485f;
|
||||
}
|
||||
|
||||
.timeline-row.done .timeline-dot { background: var(--green); }
|
||||
.timeline-row.active {
|
||||
border-color: var(--cyan);
|
||||
background: rgba(56, 189, 248, 0.09);
|
||||
}
|
||||
.timeline-row.active .timeline-dot { background: var(--cyan); box-shadow: 0 0 12px var(--cyan); }
|
||||
.timeline-row.skipped { opacity: 0.48; }
|
||||
.timeline-row.skipped .timeline-dot { background: #475569; }
|
||||
|
||||
.pid-sparkline {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
margin: 4px 0 10px;
|
||||
border: 1px solid #243e57;
|
||||
border-radius: 12px;
|
||||
background: #071423;
|
||||
}
|
||||
|
||||
.pid-sparkline path {
|
||||
stroke: #223c55;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.pid-sparkline polyline {
|
||||
fill: none;
|
||||
stroke: var(--cyan);
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.log-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.log-filters button,
|
||||
.small-button {
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-filters button.active {
|
||||
border-color: var(--cyan);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
|
||||
.event-row {
|
||||
grid-template-columns: 62px 84px 116px 88px 128px minmax(260px, 1fr) 68px;
|
||||
}
|
||||
|
||||
.scenario-buttons button em {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #b6c6d8;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.bottom-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user