chore: rename repository to NODEDC MISSION CORE
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_TARGET=http://127.0.0.1:8000
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,256 @@
|
||||
# NODEDC MISSION CORE · Control Station
|
||||
|
||||
Универсальный браузерный пункт управления Mission Core на React 19, TypeScript и
|
||||
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
|
||||
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
|
||||
device adapter, но структура интерфейса от него не зависит.
|
||||
|
||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
|
||||
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
|
||||
потока или совместимой RRD-записи.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
| Контур | Состояние | Что это означает |
|
||||
| --- | --- | --- |
|
||||
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
|
||||
|
||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
|
||||
показывают `—`.
|
||||
|
||||
## Архитектура данных
|
||||
|
||||
```text
|
||||
K1 MQTT :1883, read-only
|
||||
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
||||
└── bounded latest-wins preview queue (32 сообщения)
|
||||
└── проверенный protobuf/LZ4 decoder
|
||||
└── Rerun Points3D + Transform3D + LineStrips3D
|
||||
└── gRPC/proxy TCP 9876
|
||||
└── rerun_grpc_url в REST/WebSocket state
|
||||
└── встроенный @rerun-io/web-viewer
|
||||
└── пространственная сцена Mission Core
|
||||
|
||||
Mission Core Control Station ←→ REST /api/* + WebSocket /api/events
|
||||
FastAPI на 127.0.0.1:8000
|
||||
CoreBluetooth + live/replay runtime
|
||||
```
|
||||
|
||||
В момент готовности `RerunBridge` backend публикует адрес вида
|
||||
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
|
||||
если оператор не указал ручной source. После остановки приёма URL и встроенный
|
||||
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
|
||||
траекторию и метрики и использует тот же listener. Он закрывается вместе с
|
||||
процессом `k1link serve`.
|
||||
|
||||
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
|
||||
совместимость со старым контрактом, но текущий runtime держит их пустыми.
|
||||
|
||||
## Фиксированная оболочка
|
||||
|
||||
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
|
||||
всех функциональных модулей:
|
||||
|
||||
1. `AppHeader` — марка NODEDC MISSION CORE, выбор архитектурного раздела и состояние
|
||||
локального backend.
|
||||
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
|
||||
выбранного раздела.
|
||||
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
|
||||
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
|
||||
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
|
||||
раскрытия внутренних панелей визуального движка.
|
||||
|
||||
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
||||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
||||
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
|
||||
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
|
||||
layout пока остаются интерфейсным контрактом.
|
||||
|
||||
## Архитектурные разделы
|
||||
|
||||
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
|
||||
страницы.
|
||||
|
||||
| Раздел | Назначение |
|
||||
| --- | --- |
|
||||
| Центр | Оперативный обзор, состояние контура и активность оператора. |
|
||||
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
|
||||
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
||||
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
|
||||
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
|
||||
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
|
||||
|
||||
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
|
||||
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
|
||||
как утверждение, что для каждой карточки уже существует backend.
|
||||
|
||||
## Установка, проверка, сборка и запуск
|
||||
|
||||
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
|
||||
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
|
||||
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
||||
|
||||
Канонический путь от корня репозитория:
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
|
||||
uv sync --group dev
|
||||
|
||||
cd apps/control-station
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run build
|
||||
|
||||
cd ../..
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
|
||||
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
|
||||
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
|
||||
Wi-Fi.
|
||||
|
||||
Для разработки интерфейса после запуска backend:
|
||||
|
||||
```bash
|
||||
cd apps/control-station
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
|
||||
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
|
||||
`VITE_API_TARGET`. Preview production-сборки запускается командой
|
||||
`npm run preview` на `http://127.0.0.1:4173`.
|
||||
|
||||
## Операторский путь для текущего K1 adapter
|
||||
|
||||
1. Запустить `uv run k1link serve` и открыть Mission Core Control Station.
|
||||
2. Выбрать **Парк → Локальное устройство**.
|
||||
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
|
||||
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
|
||||
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
|
||||
оператор.
|
||||
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||
reviewed provisioning-запись без автоматических повторов.
|
||||
6. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
|
||||
останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
частота, число точек, задержка и пропуски preview появятся после прихода
|
||||
сообщений K1.
|
||||
|
||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
||||
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
|
||||
0 ошибок декодирования.
|
||||
|
||||
## Источники Rerun
|
||||
|
||||
Live/replay runtime автоматически назначает первый URL из примера. В
|
||||
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
|
||||
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
|
||||
зафиксированной в `package.json` версии:
|
||||
|
||||
```text
|
||||
rerun+http://127.0.0.1:9876/proxy
|
||||
http://127.0.0.1:8080/recording.rrd
|
||||
https://example.internal/recording.rrd
|
||||
```
|
||||
|
||||
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
|
||||
proxy;
|
||||
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
|
||||
- версия RRD должна быть совместима с версией Web Viewer;
|
||||
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
|
||||
автоматическим source;
|
||||
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||
|
||||
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||
оболочку. Backend принимает и применяет к Rerun размер точки `0.5–12.0`, режимы
|
||||
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
|
||||
Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака,
|
||||
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
|
||||
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
|
||||
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
|
||||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
||||
а не готовую семантическую классификацию.
|
||||
|
||||
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
|
||||
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
|
||||
в памяти текущей страницы и не записывается на диск.
|
||||
|
||||
## Контракт локального API
|
||||
|
||||
| Метод | Route | Тело / назначение |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Проверка локального сервиса. |
|
||||
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
|
||||
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
|
||||
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
|
||||
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
|
||||
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
|
||||
| `POST` | `/api/session/stop` | Остановка активного источника. |
|
||||
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
|
||||
| `WS` | `/api/events` | Периодические snapshots для live UI. |
|
||||
|
||||
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
|
||||
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
|
||||
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
|
||||
пустыми. OpenAPI доступен по `/api/docs`.
|
||||
|
||||
## Карта исходников frontend
|
||||
|
||||
| Файл | Ответственность |
|
||||
| --- | --- |
|
||||
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
||||
| `src/workspaces/DeviceWorkspace.tsx` | Реальный K1 BLE/Wi-Fi/live/replay adapter UI. |
|
||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
|
||||
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
|
||||
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
|
||||
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
||||
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. |
|
||||
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
|
||||
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
||||
|
||||
## Safety и чувствительные данные
|
||||
|
||||
- Mission Core Control Station и credential endpoint доступны только на loopback.
|
||||
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
|
||||
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
|
||||
connector-level authentication или TLS. Этот порт допустим только в доверенной
|
||||
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
|
||||
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
|
||||
случайные GATT writes и автоматические повторы запрещены.
|
||||
- MQTT live/replay не публикует команды устройству. Запуск и остановка
|
||||
физического сканирования остаются за кнопкой K1.
|
||||
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
|
||||
перегрузке preview может быть отброшен, raw evidence сохраняется.
|
||||
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
|
||||
накопления viewer использует session-local `stream_time`. `capture_time` — не
|
||||
доказанный timestamp сенсора K1 и не photon-to-screen latency.
|
||||
- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует;
|
||||
текущая Rerun-сцена содержит только облако точек и позу/траекторию, а
|
||||
операционные метрики отображаются внешней оболочкой Mission Core.
|
||||
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
|
||||
траекторию и карту помещения. В репозиторий попадают только redacted manifests
|
||||
и безопасная документация.
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="NODEDC MISSION CORE — наблюдение, данные и планирование миссий беспилотных аппаратов."
|
||||
/>
|
||||
<meta name="theme-color" content="#08090b" />
|
||||
<title>NODEDC MISSION CORE</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
<path class="cls-2" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15ZM140.43 18.64c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09ZM188.88 18.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75ZM205.15 9.75c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
|
||||
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,766 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderAvatar,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderProfileButton,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
useApplicationWorkspace,
|
||||
type ApplicationPanelUtilityAction,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import type { ViewerSettings } from "./api";
|
||||
import {
|
||||
rootById,
|
||||
roots,
|
||||
workspaceById,
|
||||
workspacesForRoot,
|
||||
type RootId,
|
||||
} from "./productModel";
|
||||
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { useK1Console } from "./useK1Console";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import "./styles/scene-windows.css";
|
||||
|
||||
type SceneToolWindowId = "sources" | "display" | "layers" | "layout";
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
|
||||
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
|
||||
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
|
||||
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
|
||||
];
|
||||
|
||||
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
|
||||
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
|
||||
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
|
||||
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
|
||||
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
|
||||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
||||
];
|
||||
|
||||
function toViewerSettings(settings: SceneSettings): ViewerSettings {
|
||||
return {
|
||||
point_size: settings.pointSize,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
custom_color: settings.customColor,
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
show_points: settings.showPoints,
|
||||
show_trajectory: settings.showTrajectory,
|
||||
show_grid: settings.showGrid,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeViewerSettings(
|
||||
current: SceneSettings,
|
||||
remote: ViewerSettings,
|
||||
): SceneSettings {
|
||||
const next = {
|
||||
...current,
|
||||
pointSize: remote.point_size,
|
||||
colorMode: remote.color_mode,
|
||||
palette: remote.palette,
|
||||
customColor: remote.custom_color,
|
||||
accumulationSeconds: remote.accumulation_seconds,
|
||||
showPoints: remote.show_points,
|
||||
showTrajectory: remote.show_trajectory,
|
||||
showGrid: remote.show_grid,
|
||||
};
|
||||
const unchanged =
|
||||
current.pointSize === next.pointSize &&
|
||||
current.colorMode === next.colorMode &&
|
||||
current.palette === next.palette &&
|
||||
current.customColor === next.customColor &&
|
||||
current.accumulationSeconds === next.accumulationSeconds &&
|
||||
current.showPoints === next.showPoints &&
|
||||
current.showTrajectory === next.showTrajectory &&
|
||||
current.showGrid === next.showGrid;
|
||||
return unchanged ? current : next;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const console = useK1Console();
|
||||
const workspace = useApplicationWorkspace<string>({
|
||||
navigationOpen: false,
|
||||
contentExpanded: true,
|
||||
});
|
||||
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [sourceDraft, setSourceDraft] = useState("");
|
||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
|
||||
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
|
||||
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
|
||||
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
|
||||
const [layoutName, setLayoutName] = useState("Операторская сцена");
|
||||
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||
const automaticSourceUrl = console.state?.rerun_grpc_url?.trim() ?? "";
|
||||
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
||||
|
||||
useEffect(() => {
|
||||
const remote = console.state?.viewer_settings;
|
||||
if (!remote) return;
|
||||
setSceneSettings((current) => mergeViewerSettings(current, remote));
|
||||
if (!displayWindowOpen) {
|
||||
setDisplayDraft((current) => mergeViewerSettings(current, remote));
|
||||
}
|
||||
}, [console.state?.viewer_settings, displayWindowOpen]);
|
||||
|
||||
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
setSceneWindowOrder((current) => [
|
||||
...current.filter((candidate) => candidate !== windowId),
|
||||
windowId,
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
if (windowId === "layout") setLayoutWindowOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSceneWindow) return;
|
||||
|
||||
const closeActiveWindow = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
if (document.querySelector(".nodedc-dropdown-surface")) return;
|
||||
if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return;
|
||||
event.preventDefault();
|
||||
closeSceneWindow(activeSceneWindow);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", closeActiveWindow);
|
||||
return () => document.removeEventListener("keydown", closeActiveWindow);
|
||||
}, [activeSceneWindow, closeSceneWindow]);
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
workspace.closeView();
|
||||
workspace.openNavigation();
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
const definition = workspaceById(viewId);
|
||||
if (!definition) return;
|
||||
setActiveRoot(definition.root);
|
||||
workspace.openView(viewId);
|
||||
};
|
||||
|
||||
const openSource = () => {
|
||||
setSourceDraft(sourceUrl);
|
||||
setSourceWindowOpen(true);
|
||||
activateSceneWindow("sources");
|
||||
};
|
||||
|
||||
const openDisplay = () => {
|
||||
setDisplayDraft(sceneSettings);
|
||||
setDisplayWindowOpen(true);
|
||||
activateSceneWindow("display");
|
||||
};
|
||||
|
||||
const openLayers = () => {
|
||||
setLayerInspectorOpen(true);
|
||||
activateSceneWindow("layers");
|
||||
};
|
||||
|
||||
const openLayout = () => {
|
||||
setLayoutDraftSaved(false);
|
||||
setLayoutWindowOpen(true);
|
||||
activateSceneWindow("layout");
|
||||
};
|
||||
|
||||
const applyDisplaySettings = async () => {
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(displayDraft));
|
||||
if (applied) setSceneSettings(displayDraft);
|
||||
};
|
||||
|
||||
const applyScenePatch = async (patch: Partial<SceneSettings>) => {
|
||||
const previous = sceneSettings;
|
||||
const next = { ...sceneSettings, ...patch };
|
||||
setSceneSettings(next);
|
||||
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(next));
|
||||
if (!applied) {
|
||||
setSceneSettings(previous);
|
||||
if (!displayWindowOpen) setDisplayDraft(previous);
|
||||
}
|
||||
};
|
||||
|
||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
|
||||
if (activeDefinition?.kind === "device") {
|
||||
actions.push({
|
||||
label: "Обновить состояние локального контура",
|
||||
icon: "refresh",
|
||||
onClick: () => void console.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeDefinition?.kind === "spatial") {
|
||||
actions.push(
|
||||
{ label: "Настроить источник", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
onClick: openLayout,
|
||||
});
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandLabel="NODEDC MISSION CORE"
|
||||
left={<span className="station-label">MISSION CORE</span>}
|
||||
center={
|
||||
<>
|
||||
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
|
||||
<HeaderNavigation
|
||||
label="Архитектурные блоки пункта управления"
|
||||
value={activeRoot ?? undefined}
|
||||
items={roots.map((root) => ({ value: root.id, label: root.label }))}
|
||||
onChange={selectRoot}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void console.refresh()} title="Обновить локальный контур">
|
||||
<span className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(console.backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar label="DC" />
|
||||
</HeaderProfile>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApplicationShell
|
||||
data-nodedc-ui
|
||||
className="control-station"
|
||||
navigationOpen={workspace.navigationOpen && activeRoot !== null}
|
||||
contentOpen={workspace.contentOpen && activeDefinition !== null}
|
||||
contentExpanded={workspace.contentExpanded}
|
||||
header={header}
|
||||
stage={
|
||||
<LandingStage
|
||||
root={currentRoot}
|
||||
backendStatus={console.backendStatus}
|
||||
phase={console.state?.phase}
|
||||
message={localizeRuntimeMessage(console.state?.message)}
|
||||
onOpenObservation={() => openView("spatial-scene")}
|
||||
onOpenDevice={() => openView("local-device")}
|
||||
/>
|
||||
}
|
||||
navigation={currentRoot ? (
|
||||
<AdminNavigationPanel
|
||||
eyebrow="MISSION CORE"
|
||||
title={currentRoot.title}
|
||||
closeLabel={`Закрыть раздел «${currentRoot.label}»`}
|
||||
navigationLabel={`Рабочие поверхности раздела «${currentRoot.label}»`}
|
||||
onClose={workspace.closeNavigation}
|
||||
contexts={[
|
||||
{
|
||||
id: "local-contour",
|
||||
label: "Локальный контур",
|
||||
description: console.state?.k1_ip || "Устройство не назначено",
|
||||
icon: <Icon name="network" />,
|
||||
active: console.backendStatus !== "offline",
|
||||
},
|
||||
]}
|
||||
items={rootWorkspaces.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
icon: <Icon name={item.icon} />,
|
||||
}))}
|
||||
activeId={workspace.activeView ?? undefined}
|
||||
onItemChange={openView}
|
||||
footer={
|
||||
<>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
<Icon name="activity" />
|
||||
</span>
|
||||
<span>{rootWorkspaces.length} рабочих поверхностей</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
content={activeDefinition ? (
|
||||
<ApplicationPanel
|
||||
key={activeDefinition.id}
|
||||
eyebrow={activeDefinition.eyebrow}
|
||||
title={activeDefinition.title}
|
||||
description={activeDefinition.description}
|
||||
expanded={workspace.contentExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
headerTools={
|
||||
activeDefinition.kind === "device" ? (
|
||||
<StatusBadge tone={phaseTone(console.state?.phase)}>
|
||||
{phaseLabel(console.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
}
|
||||
utilityActions={contentActions}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
console={console}
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
state={console.state}
|
||||
backendStatus={console.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
sceneSettings={sceneSettings}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
openDisplay,
|
||||
openLayers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ApplicationPanel>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={sourceWindowOpen}
|
||||
title="Источники"
|
||||
subtitle="Потоки и записи пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--sources"
|
||||
data-scene-window="sources"
|
||||
data-scene-active={activeSceneWindow === "sources" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("sources")}
|
||||
onClose={() => closeSceneWindow("sources")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
}}
|
||||
>
|
||||
Сбросить адрес
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!sourceDraft.trim()}
|
||||
onClick={() => {
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
}}
|
||||
>
|
||||
Применить адрес
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["connection"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "connection",
|
||||
label: "Подключение",
|
||||
description: "Адрес потока или записи",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Состояние">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
|
||||
</span>
|
||||
</ControlRow>
|
||||
{effectiveSourceUrl ? (
|
||||
<ControlRow label="Активный адрес" layout="stack">
|
||||
<code className="scene-window-code">{effectiveSourceUrl}</code>
|
||||
</ControlRow>
|
||||
) : null}
|
||||
<ControlRow label="Автоматический" layout="stack">
|
||||
<code className="scene-window-code">
|
||||
{automaticSourceUrl || "Локальный источник ещё не опубликован"}
|
||||
</code>
|
||||
</ControlRow>
|
||||
<TextField
|
||||
label="Ручной адрес"
|
||||
hint="необязательно"
|
||||
value={sourceDraft}
|
||||
onChange={(event) => setSourceDraft(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="rerun+http://127.0.0.1:9876/proxy"
|
||||
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "formats",
|
||||
label: "Поддерживаемые адреса",
|
||||
description: "Rerun gRPC и RRD",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Живой поток" layout="stack">
|
||||
<code className="scene-window-code">rerun+http://…/proxy</code>
|
||||
</ControlRow>
|
||||
<ControlRow label="Локальная запись" layout="stack">
|
||||
<code className="scene-window-code">http://…/recording.rrd</code>
|
||||
</ControlRow>
|
||||
<ControlRow label="Удалённая запись" layout="stack">
|
||||
<code className="scene-window-code">https://…/recording.rrd</code>
|
||||
</ControlRow>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "adapter",
|
||||
label: "Локальный адаптер",
|
||||
description: "Транспорт устройства",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Контур">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(console.backendStatus)}
|
||||
</span>
|
||||
</ControlRow>
|
||||
<p className="scene-window-note">
|
||||
Встроенный адаптер публикует локальный Rerun gRPC автоматически после запуска
|
||||
живого приёма или повтора записи. Ручной адрес для этого не требуется.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={displayWindowOpen}
|
||||
title="Отображение"
|
||||
subtitle="Параметры пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--display"
|
||||
data-scene-window="display"
|
||||
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={console.pendingAction === "viewer"}
|
||||
onClick={() => void applyDisplaySettings()}
|
||||
>
|
||||
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<Inspector
|
||||
defaultOpen={["points"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={displayDraft.pointSize}
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
|
||||
/>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={paletteOptions}
|
||||
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
{displayDraft.palette === "custom" ? (
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек"
|
||||
value={displayDraft.customColor}
|
||||
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
|
||||
/>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scene",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
||||
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layerInspectorOpen}
|
||||
title="Слои сцены"
|
||||
subtitle="Сущности пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--layers"
|
||||
data-scene-window="layers"
|
||||
data-scene-active={activeSceneWindow === "layers" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("layers")}
|
||||
onClose={() => closeSceneWindow("layers")}
|
||||
>
|
||||
<div className="layer-inspector-intro">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
|
||||
</span>
|
||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["geometry"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "geometry",
|
||||
label: "Геометрия",
|
||||
description: "Облако точек и путь",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "frames",
|
||||
label: "Координаты и камеры",
|
||||
description: "Преобразования и области обзора",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "perception",
|
||||
label: "Объекты и маски",
|
||||
description: "Ожидают внешние обработчики",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layoutWindowOpen}
|
||||
title="Компоновка рабочей области"
|
||||
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
|
||||
size="sm"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--layout"
|
||||
data-scene-window="layout"
|
||||
data-scene-active={activeSceneWindow === "layout" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("layout")}
|
||||
onClose={() => closeSceneWindow("layout")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!layoutName.trim()}
|
||||
onClick={() => setLayoutDraftSaved(true)}
|
||||
>
|
||||
Зафиксировать черновик
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<TextField
|
||||
label="Название компоновки"
|
||||
value={layoutName}
|
||||
onChange={(event) => {
|
||||
setLayoutName(event.target.value);
|
||||
setLayoutDraftSaved(false);
|
||||
}}
|
||||
placeholder="Название профиля"
|
||||
/>
|
||||
<div className="modal-contract-note" data-tone={layoutDraftSaved ? "success" : "warning"}>
|
||||
<Icon name={layoutDraftSaved ? "check" : "save"} />
|
||||
<div>
|
||||
<strong>{layoutDraftSaved ? "Черновик зафиксирован в текущем сеансе" : "Экспорт RBL ещё не подключён"}</strong>
|
||||
<p>
|
||||
{layoutDraftSaved
|
||||
? "Это состояние интерфейса без записи на диск. Будущий адаптер сохранит профиль Rerun рядом с кодом."
|
||||
: "Окно и контракт сохранения готовы; запись файла не имитируется."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
export interface BleDevice {
|
||||
device_id: string;
|
||||
name?: string | null;
|
||||
rssi?: number | null;
|
||||
address?: string | null;
|
||||
connectable?: boolean | null;
|
||||
likely_k1?: boolean | null;
|
||||
}
|
||||
|
||||
export type SourceMode = "idle" | "live" | "replay";
|
||||
|
||||
export interface K1Metrics {
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
pipeline_ms?: number | null;
|
||||
end_to_end_ms?: number | null;
|
||||
frame_rate?: number | null;
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
[key: string]: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface ConsoleState {
|
||||
phase?: string | null;
|
||||
message?: string | null;
|
||||
devices?: BleDevice[];
|
||||
selected_device_id?: string | null;
|
||||
k1_ip?: string | null;
|
||||
foxglove_ws_url?: string | null;
|
||||
foxglove_viewer_url?: string | null;
|
||||
rerun_grpc_url?: string | null;
|
||||
viewer_settings?: ViewerSettings | null;
|
||||
source_mode?: SourceMode | null;
|
||||
metrics?: K1Metrics;
|
||||
}
|
||||
|
||||
export interface ViewerSettings {
|
||||
point_size: number;
|
||||
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
service?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ScanRequest {
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
ssid: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LiveRequest {
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface ReplayRequest {
|
||||
path: string;
|
||||
speed?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function unwrapState(payload: unknown): ConsoleState {
|
||||
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
|
||||
|
||||
if (!isRecord(value)) {
|
||||
throw new ApiError("Локальный сервер вернул некорректное состояние.");
|
||||
}
|
||||
|
||||
return value as ConsoleState;
|
||||
}
|
||||
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
let body: unknown;
|
||||
|
||||
if (bodyText) {
|
||||
try {
|
||||
body = JSON.parse(bodyText) as unknown;
|
||||
} catch {
|
||||
body = bodyText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
isRecord(body) && typeof body.detail === "string"
|
||||
? body.detail
|
||||
: typeof body === "string" && body.trim()
|
||||
? body.trim()
|
||||
: `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
|
||||
throw new ApiError(
|
||||
detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postState(path: string, body?: object): Promise<ConsoleState> {
|
||||
const payload = await requestJson(path, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (payload === undefined) {
|
||||
return api.getState();
|
||||
}
|
||||
|
||||
return unwrapState(payload);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async getHealth(): Promise<HealthResponse> {
|
||||
const payload = await requestJson("/api/health");
|
||||
if (!isRecord(payload)) {
|
||||
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
|
||||
}
|
||||
return payload as HealthResponse;
|
||||
},
|
||||
|
||||
async getState(): Promise<ConsoleState> {
|
||||
return unwrapState(await requestJson("/api/state"));
|
||||
},
|
||||
|
||||
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/ble/scan", body);
|
||||
},
|
||||
|
||||
connect(body: ConnectRequest): Promise<ConsoleState> {
|
||||
return postState("/api/connect", body);
|
||||
},
|
||||
|
||||
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/session/live", body);
|
||||
},
|
||||
|
||||
startReplay(body: ReplayRequest): Promise<ConsoleState> {
|
||||
return postState("/api/session/replay", body);
|
||||
},
|
||||
|
||||
stopSession(): Promise<ConsoleState> {
|
||||
return postState("/api/session/stop");
|
||||
},
|
||||
|
||||
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
|
||||
return postState("/api/viewer/settings", body);
|
||||
},
|
||||
};
|
||||
|
||||
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
function eventSocketUrl(): string {
|
||||
const url = new URL("/api/events", window.location.href);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function openEventSocket(
|
||||
onState: (state: ConsoleState) => void,
|
||||
onStatus: (status: EventSocketStatus) => void,
|
||||
): () => void {
|
||||
onStatus("connecting");
|
||||
const socket = new WebSocket(eventSocketUrl());
|
||||
|
||||
socket.addEventListener("open", () => onStatus("open"));
|
||||
socket.addEventListener("message", (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(String(event.data)) as unknown;
|
||||
onState(unwrapState(payload));
|
||||
} catch {
|
||||
// The REST poll remains authoritative if an unrelated event is received.
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => onStatus("error"));
|
||||
socket.addEventListener("close", () => onStatus("closed"));
|
||||
|
||||
return () => socket.close(1000, "Пункт управления закрыт");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { RootDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
export interface LandingStageProps {
|
||||
root: RootDefinition | null;
|
||||
backendStatus: BackendStatus;
|
||||
phase?: string | null;
|
||||
message?: string | null;
|
||||
onOpenObservation: () => void;
|
||||
onOpenDevice: () => void;
|
||||
}
|
||||
|
||||
export function LandingStage({
|
||||
root,
|
||||
backendStatus,
|
||||
phase,
|
||||
message,
|
||||
onOpenObservation,
|
||||
onOpenDevice,
|
||||
}: LandingStageProps) {
|
||||
return (
|
||||
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
|
||||
<h1>{root?.title ?? "Mission Core"}</h1>
|
||||
<p>
|
||||
{root?.statement ??
|
||||
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
|
||||
</p>
|
||||
<div className="landing-stage__actions">
|
||||
<Button variant="primary" icon={<Icon name="globe" />} onClick={onOpenObservation}>
|
||||
Пространственная сцена
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
|
||||
Локальное устройство
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="landing-stage__status">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСТОЧНИК</span>
|
||||
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
|
||||
</div>
|
||||
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
|
||||
</div>
|
||||
|
||||
<footer className="landing-stage__footer">
|
||||
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { GlassSurface } from "@nodedc/ui-react";
|
||||
|
||||
export interface MetricCardProps {
|
||||
eyebrow: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
detail: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export function MetricCard({ eyebrow, value, unit, detail, featured = false }: MetricCardProps) {
|
||||
return (
|
||||
<GlassSurface className="metric-card" padding="md" data-featured={featured ? "true" : undefined}>
|
||||
<span className="metric-card__eyebrow">{eyebrow}</span>
|
||||
<div className="metric-card__reading">
|
||||
<strong>{value}</strong>
|
||||
{unit ? <span>{unit}</span> : null}
|
||||
</div>
|
||||
<p>{detail}</p>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export interface RerunSelection {
|
||||
entityPath: string;
|
||||
viewName?: string;
|
||||
position?: [number, number, number];
|
||||
}
|
||||
|
||||
export interface RerunViewportProps {
|
||||
sourceUrl: string;
|
||||
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
||||
onSelectionChange?: (selection: RerunSelection | null) => void;
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
sourceUrl,
|
||||
onStatusChange,
|
||||
onSelectionChange,
|
||||
}: RerunViewportProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSource = sourceUrl.trim();
|
||||
if (!normalizedSource || !hostRef.current) {
|
||||
setStatus("idle");
|
||||
onStatusChange?.("idle");
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let stopViewer: (() => void) | undefined;
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
setStatus("loading");
|
||||
onStatusChange?.("loading");
|
||||
|
||||
void import("@rerun-io/web-viewer")
|
||||
.then(async ({ WebViewer }) => {
|
||||
const viewer = new WebViewer();
|
||||
stopViewer = () => {
|
||||
// Remove the gRPC receiver explicitly before tearing down WASM. This
|
||||
// closes the browser-side stream promptly so the local SDK server can
|
||||
// release its port before the next device session starts.
|
||||
try {
|
||||
viewer.close(normalizedSource);
|
||||
} catch {
|
||||
// The viewer can already be stopped after a startup failure.
|
||||
}
|
||||
viewer.stop();
|
||||
};
|
||||
|
||||
await viewer.start(
|
||||
normalizedSource,
|
||||
hostRef.current,
|
||||
{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
theme: "dark",
|
||||
hide_welcome_screen: true,
|
||||
enable_history: false,
|
||||
allow_fullscreen: false,
|
||||
},
|
||||
null,
|
||||
);
|
||||
if (disposed) {
|
||||
stopViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
viewer.override_panel_state("top", "hidden");
|
||||
viewer.override_panel_state("blueprint", "hidden");
|
||||
viewer.override_panel_state("selection", "hidden");
|
||||
viewer.override_panel_state("time", "hidden");
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("selection_change", (event) => {
|
||||
const entity = event.items.find((item) => item.type === "entity");
|
||||
if (!entity || entity.type !== "entity") {
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
onSelectionChange?.({
|
||||
entityPath: entity.entity_path,
|
||||
viewName: entity.view_name,
|
||||
position: entity.position,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
setStatus("ready");
|
||||
onStatusChange?.("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
const message = "Не удалось запустить встроенный визуализатор.";
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
stopViewer?.();
|
||||
onSelectionChange?.(null);
|
||||
};
|
||||
}, [onSelectionChange, onStatusChange, sourceUrl]);
|
||||
|
||||
return (
|
||||
<div className="rerun-viewport" data-status={status}>
|
||||
<div ref={hostRef} className="rerun-viewport__canvas" />
|
||||
{status === "loading" ? (
|
||||
<div className="rerun-viewport__notice" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Подключаем визуальный источник</strong>
|
||||
<span>Запуск встроенного визуализатора Rerun.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
|
||||
<div>
|
||||
<strong>Источник не открыт</strong>
|
||||
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { applyNodedcTheme } from "@nodedc/ui-core";
|
||||
import "@nodedc/tokens/tokens.css";
|
||||
import "@nodedc/tokens/themes.css";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Не найден корневой элемент пункта управления.");
|
||||
}
|
||||
|
||||
rootElement.classList.add("nodedc-ui-root");
|
||||
rootElement.dataset.nodedcUi = "";
|
||||
applyNodedcTheme(rootElement, { theme: "dark" });
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||
[
|
||||
/Device was not rediscovered; keep the K1 powered and nearby\.?/gi,
|
||||
"Устройство не обнаружено повторно; оставьте его включённым и рядом.",
|
||||
],
|
||||
[/Foxglove/gi, "локальный мост визуализации"],
|
||||
[/MacBook/gi, "компьютер"],
|
||||
[/\bK1\b/g, "устройство"],
|
||||
];
|
||||
|
||||
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
|
||||
const localized = runtimeMessageReplacements.reduce(
|
||||
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
|
||||
message,
|
||||
);
|
||||
|
||||
const withoutTechnicalTerms = localized.replace(
|
||||
/\b(?:API|BLE|Bluetooth|gRPC|IP|JSON|K1MQTT|MQTT|Rerun|RRD|TSV|UUID|Wi-Fi)\b/gi,
|
||||
"",
|
||||
);
|
||||
if (/[A-Za-z]{3,}/.test(withoutTechnicalTerms)) {
|
||||
return "Операция не выполнена. Технические подробности сохранены в журнале сервера.";
|
||||
}
|
||||
|
||||
return localized;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { K1Metrics } from "./api";
|
||||
import type { BackendStatus } from "./useK1Console";
|
||||
|
||||
const phaseLabels: Record<string, string> = {
|
||||
idle: "Ожидание",
|
||||
scanning: "Поиск Bluetooth",
|
||||
device_selected: "Устройство выбрано",
|
||||
provisioning: "Передача настроек Wi‑Fi",
|
||||
connecting: "Подключение",
|
||||
connected: "Устройство подключено",
|
||||
starting_live: "Запуск потока",
|
||||
live: "Поток в реальном времени",
|
||||
replay: "Повтор записи",
|
||||
stopping: "Остановка",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function phaseLabel(phase: string | null | undefined): string {
|
||||
if (!phase) return "Нет состояния";
|
||||
return phaseLabels[phase] ?? "Неизвестное состояние";
|
||||
}
|
||||
|
||||
export function phaseTone(phase: string | null | undefined): StatusTone {
|
||||
if (!phase) return "neutral";
|
||||
if (phase === "error") return "danger";
|
||||
if (["connected", "live", "replay"].includes(phase)) return "success";
|
||||
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) {
|
||||
return "accent";
|
||||
}
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function backendLabel(status: BackendStatus): string {
|
||||
return {
|
||||
checking: "Проверка контура",
|
||||
online: "Контур доступен",
|
||||
degraded: "Контур ограничен",
|
||||
offline: "Контур недоступен",
|
||||
}[status];
|
||||
}
|
||||
|
||||
export function backendTone(status: BackendStatus): StatusTone {
|
||||
if (status === "online") return "success";
|
||||
if (status === "degraded" || status === "checking") return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export function eventStatusLabel(status: string): string {
|
||||
return {
|
||||
connecting: "подключение",
|
||||
open: "подключён",
|
||||
closed: "закрыт",
|
||||
error: "ошибка",
|
||||
}[status] ?? "неизвестно";
|
||||
}
|
||||
|
||||
export function finiteMetric(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function pipelineLatency(metrics: K1Metrics | undefined): number | null {
|
||||
if (!metrics) return null;
|
||||
const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
const segments = [
|
||||
finiteMetric(metrics.mqtt_to_decode_ms),
|
||||
finiteMetric(metrics.publish_ms),
|
||||
].filter((value): value is number => value !== null);
|
||||
|
||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function sourceModeLabel(mode: string | null | undefined): string {
|
||||
if (mode === "live") return "Реальное время";
|
||||
if (mode === "replay") return "Повтор записи";
|
||||
if (mode === "idle") return "Ожидание";
|
||||
return "Неизвестно";
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
import type { IconName } from "@nodedc/ui-react";
|
||||
|
||||
export type RootId = "center" | "fleet" | "observation" | "missions" | "data" | "system";
|
||||
|
||||
export type WorkspaceKind =
|
||||
| "overview"
|
||||
| "device"
|
||||
| "spatial"
|
||||
| "cameras"
|
||||
| "map"
|
||||
| "timeline"
|
||||
| "missions"
|
||||
| "catalog";
|
||||
|
||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||
|
||||
export interface Capability {
|
||||
label: string;
|
||||
description: string;
|
||||
status: CapabilityStatus;
|
||||
}
|
||||
|
||||
export interface CapabilityGroup {
|
||||
title: string;
|
||||
description: string;
|
||||
capabilities: Capability[];
|
||||
}
|
||||
|
||||
export interface WorkspaceDefinition {
|
||||
id: string;
|
||||
root: RootId;
|
||||
label: string;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
description: string;
|
||||
icon: IconName;
|
||||
kind: WorkspaceKind;
|
||||
groups: CapabilityGroup[];
|
||||
}
|
||||
|
||||
export interface RootDefinition {
|
||||
id: RootId;
|
||||
label: string;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
description: string;
|
||||
statement: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const active = (label: string, description: string): Capability => ({
|
||||
label,
|
||||
description,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const ready = (label: string, description: string): Capability => ({
|
||||
label,
|
||||
description,
|
||||
status: "ready",
|
||||
});
|
||||
|
||||
const contract = (label: string, description: string): Capability => ({
|
||||
label,
|
||||
description,
|
||||
status: "contract",
|
||||
});
|
||||
|
||||
const later = (label: string, description: string): Capability => ({
|
||||
label,
|
||||
description,
|
||||
status: "later",
|
||||
});
|
||||
|
||||
export const roots: RootDefinition[] = [
|
||||
{
|
||||
id: "center",
|
||||
label: "Центр",
|
||||
title: "Центр управления",
|
||||
eyebrow: "ПУНКТ УПРАВЛЕНИЯ",
|
||||
description: "Единая оперативная картина, состояние контура и события оператора.",
|
||||
statement: "Наблюдать весь контур, быстро видеть отклонения и переходить к действию.",
|
||||
accent: "ОПЕРАТИВНЫЙ КОНТУР",
|
||||
},
|
||||
{
|
||||
id: "fleet",
|
||||
label: "Парк",
|
||||
title: "Аппараты и устройства",
|
||||
eyebrow: "ПАРК / УСТРОЙСТВА",
|
||||
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
|
||||
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
|
||||
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
|
||||
},
|
||||
{
|
||||
id: "observation",
|
||||
label: "Наблюдение",
|
||||
title: "Наблюдение",
|
||||
eyebrow: "СИТУАЦИОННАЯ ОСВЕДОМЛЁННОСТЬ",
|
||||
description: "Пространственная сцена, камеры, карты, объекты, телеметрия и время.",
|
||||
statement: "Свести все сенсоры в синхронную и управляемую операторскую картину.",
|
||||
accent: "ЗРЕНИЕ И ПРОСТРАНСТВО",
|
||||
},
|
||||
{
|
||||
id: "missions",
|
||||
label: "Миссии",
|
||||
title: "Миссии",
|
||||
eyebrow: "УПРАВЛЕНИЕ МИССИЯМИ",
|
||||
description: "Планирование, маршруты, сценарии и наблюдение за исполнением.",
|
||||
statement: "Собрать задачу из точек, ограничений и действий до передачи на борт.",
|
||||
accent: "ПЛАНИРОВАНИЕ И ИСПОЛНЕНИЕ",
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
label: "Данные",
|
||||
title: "Данные и записи",
|
||||
eyebrow: "РАБОЧАЯ ОБЛАСТЬ ДАННЫХ",
|
||||
description: "Сессии, потоки, сущности, воспроизведение и экспорт доказательств.",
|
||||
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
|
||||
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
label: "Система",
|
||||
title: "Система",
|
||||
eyebrow: "УПРАВЛЕНИЕ ПЛАТФОРМОЙ",
|
||||
description: "Модули, интеграции, сеть, аудит и параметры операторского пункта.",
|
||||
statement: "Подключать новые возможности модульно, не связывая интерфейс с одним устройством.",
|
||||
accent: "МОДУЛИ И ИНТЕГРАЦИИ",
|
||||
},
|
||||
];
|
||||
|
||||
export const workspaces: WorkspaceDefinition[] = [
|
||||
{
|
||||
id: "command-overview",
|
||||
root: "center",
|
||||
label: "Оперативный обзор",
|
||||
title: "Оперативный обзор",
|
||||
eyebrow: "ЦЕНТР / СЕЙЧАС",
|
||||
description: "Живое состояние локального контура и быстрые переходы к рабочим поверхностям.",
|
||||
icon: "activity",
|
||||
kind: "overview",
|
||||
groups: [
|
||||
{
|
||||
title: "Оперативная картина",
|
||||
description: "Состояние, которое уже приходит из локального адаптера.",
|
||||
capabilities: [
|
||||
active("Состояние устройства", "Фаза подключения, адрес и активный режим источника."),
|
||||
active("Метрики потока", "Частота кадров, точки в кадре, задержка и пропуски предпросмотра."),
|
||||
ready("Единый индикатор готовности", "Нормализованная готовность сенсоров и транспорта."),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Будущий контур",
|
||||
description: "Сводка нескольких аппаратов без изменения оболочки.",
|
||||
capabilities: [
|
||||
contract("Парк на карте", "Положение, связь и назначенная миссия каждого аппарата."),
|
||||
contract("Очередь тревог", "Отклонения, потери связи и запросы оператора."),
|
||||
later("Командные роли", "Разделение наблюдения, планирования и подтверждения действий."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contour-health",
|
||||
root: "center",
|
||||
label: "Состояние контура",
|
||||
title: "Состояние контура",
|
||||
eyebrow: "ЦЕНТР / СОСТОЯНИЕ",
|
||||
description: "Сенсоры, транспорт, вычисления и внешние сервисы в одном диагностическом дереве.",
|
||||
icon: "shield",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Критический путь",
|
||||
description: "От физического устройства до операторского интерфейса.",
|
||||
capabilities: [
|
||||
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
|
||||
active("Приём данных", "MQTT, декодирование облака точек и позы."),
|
||||
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
|
||||
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "operator-activity",
|
||||
root: "center",
|
||||
label: "Активность",
|
||||
title: "Активность оператора",
|
||||
eyebrow: "ЦЕНТР / АКТИВНОСТЬ",
|
||||
description: "Хронология подключений, запусков, остановок и изменений конфигурации.",
|
||||
icon: "list",
|
||||
kind: "timeline",
|
||||
groups: [
|
||||
{
|
||||
title: "События",
|
||||
description: "Операции интерфейса и ответы локального контура.",
|
||||
capabilities: [
|
||||
active("Текущие статусы", "REST и WebSocket уже передают фазу и текст операции."),
|
||||
contract("Журнал действий", "Нормализованные события с пользователем, временем и результатом."),
|
||||
later("Подпись подтверждений", "Аудит опасных команд и изменение полномочий."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "vehicles",
|
||||
root: "fleet",
|
||||
label: "Аппараты",
|
||||
title: "Реестр аппаратов",
|
||||
eyebrow: "ПАРК / РЕЕСТР",
|
||||
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
|
||||
icon: "apps",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Идентичность аппарата",
|
||||
description: "Никакой привязки продуктовой модели к конкретному производителю.",
|
||||
capabilities: [
|
||||
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
|
||||
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
|
||||
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
|
||||
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local-device",
|
||||
root: "fleet",
|
||||
label: "Локальное устройство",
|
||||
title: "Локальное устройство",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Рабочий путь BLE → Wi‑Fi → поток для первого подключённого устройства.",
|
||||
icon: "network",
|
||||
kind: "device",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "payload",
|
||||
root: "fleet",
|
||||
label: "Сенсоры и каналы",
|
||||
title: "Сенсоры и каналы",
|
||||
eyebrow: "ПАРК / ПОЛЕЗНАЯ НАГРУЗКА",
|
||||
description: "Полезная нагрузка аппарата и каналы данных, которые она публикует.",
|
||||
icon: "grid",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Пространственные сенсоры",
|
||||
description: "Единый контракт для разных источников.",
|
||||
capabilities: [
|
||||
active("Облако точек", "Доказанный декодированный поток точек текущего устройства."),
|
||||
active("Поза и траектория", "Доказанный поток позы и накопление пути."),
|
||||
contract("Камеры", "Изображение, видео, глубина, внутренние и внешние параметры."),
|
||||
contract("Навигация", "IMU, GNSS/RTK, одометрия и система координат."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "configurations",
|
||||
root: "fleet",
|
||||
label: "Компоновки",
|
||||
title: "Компоновки борта",
|
||||
eyebrow: "ПАРК / КОМПОНОВКИ",
|
||||
description: "Версионируемые сочетания сенсоров, вычислителей, транспорта и питания.",
|
||||
icon: "settings",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Шаблон компоновки",
|
||||
description: "Повторяемая конфигурация для одинаковых аппаратов.",
|
||||
capabilities: [
|
||||
contract("Состав модулей", "Сенсоры, адаптеры, обработчики и зависимости."),
|
||||
contract("Калибровки", "Системы координат, преобразования, внутренние и внешние параметры."),
|
||||
contract("Профиль сети", "Локальная сеть, ячеистая сеть, LTE и серверный маршрут."),
|
||||
later("Развёртывание на борт", "Пакет конфигурации и контролируемое обновление."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "spatial-scene",
|
||||
root: "observation",
|
||||
label: "Пространственная сцена",
|
||||
title: "Пространственная сцена",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / ЭФИР",
|
||||
description: "Облако точек, траектория, преобразования и пространственные слои в единой 3D-сцене.",
|
||||
icon: "globe",
|
||||
kind: "spatial",
|
||||
groups: [
|
||||
{
|
||||
title: "Базовая геометрия",
|
||||
description: "Главный контур реального времени текущего этапа.",
|
||||
capabilities: [
|
||||
ready("Облако точек (Points3D)", "Точки, радиус, интенсивность, градиент и накопление."),
|
||||
ready("Траектории (LineStrips3D)", "Траектория аппарата, маршруты и границы."),
|
||||
ready("Преобразования (Transform3D)", "Дерево координат, поза и области обзора камер."),
|
||||
contract("Поверхности и глубина", "Сетки, карты глубины и восстановленная геометрия."),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Управление сценой",
|
||||
description: "Функции движка перераспределены в интерфейс пункта управления.",
|
||||
capabilities: [
|
||||
contract("Выбор сущности", "Выбор объекта в сцене и синхронный инспектор."),
|
||||
contract("Переопределения", "Видимость, цвет, радиус, подписи и порядок отрисовки."),
|
||||
contract("Компоновка (Blueprint)", "Сохранение и загрузка продуктовой компоновки представлений."),
|
||||
contract("2D / 3D / карта", "Синхронное переключение пространственных представлений."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cameras",
|
||||
root: "observation",
|
||||
label: "Камеры",
|
||||
title: "Камеры",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / КАМЕРЫ",
|
||||
description: "Синхронные каналы видео, изображений и глубины с пространственной разметкой.",
|
||||
icon: "video",
|
||||
kind: "cameras",
|
||||
groups: [
|
||||
{
|
||||
title: "Изображение",
|
||||
description: "Встроенные классы 2D-представления.",
|
||||
capabilities: [
|
||||
contract("Изображение и видео (Image / Video)", "Живые кадры и кодированное видео на общей временной шкале."),
|
||||
contract("Карта глубины (DepthImage)", "Глубина с цветовой шкалой и масштабом в метрах."),
|
||||
contract("Перспективная камера (Pinhole)", "Калибровка, разрешение и проекция 3D↔2D."),
|
||||
contract("Тензоры (TensorView)", "Отладка карт признаков и многомерных выходов моделей."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "world-map",
|
||||
root: "observation",
|
||||
label: "Карта",
|
||||
title: "Карта и поверхность",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / КАРТА",
|
||||
description: "Географическая, высотная и локальная карта для аппарата и миссии.",
|
||||
icon: "globe",
|
||||
kind: "map",
|
||||
groups: [
|
||||
{
|
||||
title: "Представления карты",
|
||||
description: "Один интерфейс для глобальной и локальной геометрии.",
|
||||
capabilities: [
|
||||
contract("Геоточки и линии (GeoPoints / GeoLineStrings)", "Положение аппарата, трек, геозоны и маршруты."),
|
||||
contract("Карта высот (GridMap)", "Высота, уклон, неровность и проходимость."),
|
||||
contract("Воксельная карта (VoxelGridMap)", "Объёмные занятые и свободные области."),
|
||||
later("Картографический модуль", "Подключение grid_map, OctoMap или nvblox по профилю борта."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "objects",
|
||||
root: "observation",
|
||||
label: "Объекты и разметка",
|
||||
title: "Объекты и разметка",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / РАЗМЕТКА",
|
||||
description: "Детекции, сегментация, ключевые точки и связи — без привязки к конкретной модели.",
|
||||
icon: "panel",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Примитивы восприятия",
|
||||
description: "Готовые визуальные контракты; алгоритмы подключаются отдельными обработчиками.",
|
||||
capabilities: [
|
||||
contract("Рамки 2D / 3D (Boxes)", "Объекты в камере и пространстве с классом и уверенностью модели."),
|
||||
contract("Сегментация (SegmentationImage)", "Семантические и объектные маски поверх видеокадра."),
|
||||
contract("Ключевые точки (Points2D)", "Скелеты, ориентиры и траектории признаков."),
|
||||
contract("Граф связей (GraphView)", "Связи между объектами, сценами и состояниями."),
|
||||
later("Модули восприятия", "Детекторы и трекеры не входят в текущий этап интерфейса."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "telemetry",
|
||||
root: "observation",
|
||||
label: "Телеметрия",
|
||||
title: "Телеметрия",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / СИГНАЛЫ",
|
||||
description: "Числовые сигналы, состояния и диагностические таблицы на общей временной оси.",
|
||||
icon: "activity",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Временные представления",
|
||||
description: "Встроенные классы для чисел, состояний и таблиц.",
|
||||
capabilities: [
|
||||
active("Метрики потока в реальном времени", "Время до Rerun SDK, частота кадров, число точек и пропуски предпросмотра."),
|
||||
contract("Временные ряды (TimeSeriesView)", "Скалярные серии, несколько линий и диапазоны."),
|
||||
contract("Шкала состояний (StateTimelineView)", "Переходы режимов, ошибки и статусы подсистем."),
|
||||
contract("Таблица данных (DataframeView)", "Табличная диагностика и атрибуты сущностей."),
|
||||
contract("Столбчатая диаграмма (BarChartView)", "Распределения и компактные сравнения."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sensor-time",
|
||||
root: "observation",
|
||||
label: "Временная шкала",
|
||||
title: "Временная шкала",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / ВРЕМЯ",
|
||||
description: "Синхронная навигация по кадрам, событиям, сенсорам и выбранным сущностям.",
|
||||
icon: "list",
|
||||
kind: "timeline",
|
||||
groups: [
|
||||
{
|
||||
title: "Управление временем",
|
||||
description: "Единая шкала для реального времени и воспроизведения.",
|
||||
capabilities: [
|
||||
ready("Время и последовательность", "Наносекундное время или порядковые номера кадров."),
|
||||
ready("Пуск, пауза и переход", "API движка поддерживает управление воспроизведением."),
|
||||
contract("Синхронный выбор", "Кадр, точка, объект и телеметрия в одной позиции времени."),
|
||||
contract("Следовать за эфиром", "Автоматическое удержание последнего события."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mission-planner",
|
||||
root: "missions",
|
||||
label: "Планировщик",
|
||||
title: "Планировщик миссии",
|
||||
eyebrow: "МИССИИ / ЧЕРНОВИК",
|
||||
description: "Черновик миссии, аппараты, зона, маршрут, действия и ограничения.",
|
||||
icon: "edit",
|
||||
kind: "missions",
|
||||
groups: [
|
||||
{
|
||||
title: "Состав миссии",
|
||||
description: "Продуктовый контракт без отправки команд на борт.",
|
||||
capabilities: [
|
||||
contract("Назначение аппарата", "Один аппарат сейчас, несколько — в будущем."),
|
||||
contract("Зона и маршрут", "Точки, коридор, высота/скорость и геозоны."),
|
||||
contract("Полезная нагрузка", "Запуск сенсоров и требуемые выходные данные."),
|
||||
later("Проверка выполнимости", "Расчёт энергии, связи, столкновений и резервов."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "routes",
|
||||
root: "missions",
|
||||
label: "Маршруты и точки",
|
||||
title: "Маршруты и точки",
|
||||
eyebrow: "МИССИИ / МАРШРУТЫ",
|
||||
description: "Библиотека маршрутов по точкам, геозон и областей обследования.",
|
||||
icon: "globe",
|
||||
kind: "map",
|
||||
groups: [
|
||||
{
|
||||
title: "Геометрия задания",
|
||||
description: "Маршрут является отдельной версионируемой сущностью.",
|
||||
capabilities: [
|
||||
contract("Маршрутные точки", "Порядок, допуск, скорость и действие в точке."),
|
||||
contract("Коридоры", "Допустимый пространственный коридор движения."),
|
||||
contract("Области", "Области обхода, обследования и запрета."),
|
||||
later("Импорт карт", "Внешние форматы после выбора геодезической модели."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "scenarios",
|
||||
root: "missions",
|
||||
label: "Сценарии",
|
||||
title: "Сценарии",
|
||||
eyebrow: "МИССИИ / СЦЕНАРИИ",
|
||||
description: "Переиспользуемые последовательности действий и реакции на события.",
|
||||
icon: "apps",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Поведение",
|
||||
description: "Интерфейсный контракт будущего исполнителя миссий.",
|
||||
capabilities: [
|
||||
contract("Последовательность", "Взлёт/старт, маршрут, наблюдение, возврат."),
|
||||
contract("Условия", "Событие сенсора, качество связи, заряд и таймаут."),
|
||||
contract("Ветвление", "Повтор, переход, безопасная остановка."),
|
||||
later("Исполнитель на борту", "Автономное выполнение без сервера и интерфейса."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "execution",
|
||||
root: "missions",
|
||||
label: "Исполнение",
|
||||
title: "Исполнение миссии",
|
||||
eyebrow: "МИССИИ / ИСПОЛНЕНИЕ",
|
||||
description: "Наблюдение за ходом миссии и корректировки в разрешённых границах.",
|
||||
icon: "activity",
|
||||
kind: "timeline",
|
||||
groups: [
|
||||
{
|
||||
title: "Оперативное исполнение",
|
||||
description: "Интерфейс не входит в контур стабилизации или аварийного торможения.",
|
||||
capabilities: [
|
||||
contract("Прогресс", "Текущий этап, точка, отклонение и оценка завершения."),
|
||||
contract("События", "Предупреждения, отклонения и запросы подтверждения."),
|
||||
contract("Безопасные корректировки", "Пауза, продолжение и новая цель верхнего уровня."),
|
||||
later("Командный канал", "Появится только после отдельной проверки безопасности."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
root: "data",
|
||||
label: "Сессии и записи",
|
||||
title: "Сессии и записи",
|
||||
eyebrow: "ДАННЫЕ / ЗАПИСИ",
|
||||
description: "Живые и записанные эксперименты с воспроизводимым набором потоков.",
|
||||
icon: "folder",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Артефакты",
|
||||
description: "Сохраняется политика «сначала исходные данные».",
|
||||
capabilities: [
|
||||
active("Нативные захваты", "Сырые данные MQTT и обезличенный манифест."),
|
||||
active("Воспроизведение", "Воспроизведение исходной записи MQTT и проверенного TSV."),
|
||||
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
|
||||
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "streams",
|
||||
root: "data",
|
||||
label: "Потоки",
|
||||
title: "Потоки данных",
|
||||
eyebrow: "ДАННЫЕ / ПОТОКИ",
|
||||
description: "Каталог источников, транспортов, частот, задержек и схем.",
|
||||
icon: "network",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Транспорт",
|
||||
description: "Источник отделён от визуального представления.",
|
||||
capabilities: [
|
||||
active("MQTT вход", "Доказанный локальный поток текущего устройства."),
|
||||
active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."),
|
||||
ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."),
|
||||
ready("RRD по HTTP", "Открытие одной или нескольких записей."),
|
||||
later("ROS 2 / Zenoh", "Бортовой и межсетевой транспорт следующего этапа."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "entities",
|
||||
root: "data",
|
||||
label: "Слои и сущности",
|
||||
title: "Слои и сущности",
|
||||
eyebrow: "ДАННЫЕ / СУЩНОСТИ",
|
||||
description: "Единое дерево аппаратов, сенсоров, объектов, пространств и визуальных переопределений.",
|
||||
icon: "list",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Модель сущностей",
|
||||
description: "Продуктовая оболочка поверх путей сущностей Rerun.",
|
||||
capabilities: [
|
||||
contract("Иерархия", "Аппарат → сенсор → поток → сущность."),
|
||||
contract("Видимость", "Включение слоя, режим соло и временное скрытие."),
|
||||
contract("Выбор сущности", "Выбранная сущность синхронизирует сцену и инспектор."),
|
||||
contract("Переопределения", "Цвет, радиус, подписи и представление на уровне сущности."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "playback",
|
||||
root: "data",
|
||||
label: "Воспроизведение",
|
||||
title: "Воспроизведение",
|
||||
eyebrow: "ДАННЫЕ / ВОСПРОИЗВЕДЕНИЕ",
|
||||
description: "Открытие записи, выбор временной шкалы, диапазона и синхронный разбор события.",
|
||||
icon: "video",
|
||||
kind: "timeline",
|
||||
groups: [
|
||||
{
|
||||
title: "API воспроизведения",
|
||||
description: "Движок уже предоставляет программный контроль времени.",
|
||||
capabilities: [
|
||||
ready("Открыть / закрыть запись", "Адрес RRD или источник Rerun gRPC."),
|
||||
ready("Активная запись", "Выбор одной записи среди нескольких."),
|
||||
ready("Шкала времени и диапазон", "Переключение времени и чтение границ."),
|
||||
ready("Воспроизведение, пауза и переход", "Программное управление воспроизведением."),
|
||||
contract("Сравнение прогонов", "Синхронный разбор нескольких записей."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "exports",
|
||||
root: "data",
|
||||
label: "Экспорт",
|
||||
title: "Экспорт",
|
||||
eyebrow: "ДАННЫЕ / ЭКСПОРТ",
|
||||
description: "Контролируемый экспорт записей, компоновок, кадров и отчётов.",
|
||||
icon: "download",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Выходные артефакты",
|
||||
description: "Экспорт не подменяет файлы исходных данных.",
|
||||
capabilities: [
|
||||
contract("RRD", "Переносимая запись визуализируемых данных."),
|
||||
contract("RBL", "Версионируемая компоновка."),
|
||||
contract("Кадр / снимок", "Операторский снимок выбранного момента."),
|
||||
active("Обезличенный манифест", "Безопасный индекс чувствительных локальных артефактов."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "modules",
|
||||
root: "system",
|
||||
label: "Вычислительные модули",
|
||||
title: "Вычислительные модули",
|
||||
eyebrow: "СИСТЕМА / МОДУЛИ",
|
||||
description: "Контракты будущих вычислительных модулей без их запуска на текущем интерфейсном этапе.",
|
||||
icon: "apps",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Вычислительные роли",
|
||||
description: "Каждый модуль публикует стандартные выходы, интерфейс только отображает их.",
|
||||
capabilities: [
|
||||
active("Адаптер устройства", "Закрытый протокол текущего устройства уже изолирован."),
|
||||
contract("Восприятие", "Рамки, маски, ключевые точки, траектории и семантические подписи."),
|
||||
contract("Картирование", "Высота, занятость, сетки и проходимость."),
|
||||
contract("Локализация", "Поза, одометрия, TF и неопределённость."),
|
||||
contract("Исполнитель миссий", "Состояние графа миссии и действия верхнего уровня."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
root: "system",
|
||||
label: "Интеграции",
|
||||
title: "Интеграции",
|
||||
eyebrow: "СИСТЕМА / ИНТЕГРАЦИИ",
|
||||
description: "Адаптеры данных и внешние контуры с явной границей ответственности.",
|
||||
icon: "network",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Точки интеграции",
|
||||
description: "Внешние системы не определяют продуктовую навигацию.",
|
||||
capabilities: [
|
||||
active("Локальный API устройства", "Текущий контур управления FastAPI."),
|
||||
ready("Визуализатор Rerun", "Открытый веб-визуализатор встроен в пространственную рабочую поверхность."),
|
||||
contract("Мост ROS 2", "Стандартные PointCloud2, Pose, Odometry, TF и диагностика."),
|
||||
contract("Шлюз Zenoh", "Маршрутизация разрешённых потоков между бортом и сервером."),
|
||||
later("Сервисы парка", "Задачи, зарядки и координация нескольких аппаратов."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
root: "system",
|
||||
label: "Сеть",
|
||||
title: "Сеть",
|
||||
eyebrow: "СИСТЕМА / СЕТЬ",
|
||||
description: "Локальный контур, бортовая сеть, ячеистая сеть/LTE и серверные маршруты.",
|
||||
icon: "globe",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Сетевые зоны",
|
||||
description: "Потоки управления и высокой пропускной способности разделяются архитектурно.",
|
||||
capabilities: [
|
||||
active("Локальная сеть", "Устройство и рабочая станция в одной доступной сети."),
|
||||
active("Локальное управление", "Точка приёма учётных данных и браузерный контур управления ограничены локальным хостом."),
|
||||
contract("Бортовая сеть", "Сенсоры, вычислитель и маршрутизатор внутри аппарата."),
|
||||
contract("Дальний транспорт", "Ячеистая сеть или LTE передаёт предпросмотр, позу, события и изменения карты."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "audit",
|
||||
root: "system",
|
||||
label: "Журнал и аудит",
|
||||
title: "Журнал и аудит",
|
||||
eyebrow: "СИСТЕМА / АУДИТ",
|
||||
description: "Технические события, действия оператора и воспроизводимость эксперимента.",
|
||||
icon: "clipboard",
|
||||
kind: "timeline",
|
||||
groups: [
|
||||
{
|
||||
title: "Доказательность",
|
||||
description: "Git хранит код, обезличенные метаданные и правила воспроизведения.",
|
||||
capabilities: [
|
||||
active("Первичное сохранение сырья", "Сырые сообщения сохраняются до декодирования предпросмотра."),
|
||||
active("Манифест сессии", "Версии, хэши и обезличенные сведения о прогоне."),
|
||||
contract("События аудита интерфейса", "Переходы, операции и подтверждения пользователя."),
|
||||
later("Центральный сервис аудита", "Поиск и корреляция событий нескольких аппаратов."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
root: "system",
|
||||
label: "Настройки",
|
||||
title: "Настройки пункта управления",
|
||||
eyebrow: "СИСТЕМА / НАСТРОЙКИ",
|
||||
description: "Компоновки, визуальные профили, ограничения ресурсов и локальные параметры.",
|
||||
icon: "settings",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Рабочая область",
|
||||
description: "Настройки не смешиваются с конфигурацией физического аппарата.",
|
||||
capabilities: [
|
||||
contract("Профили компоновки", "Компоновки представлений для наблюдения, разбора и миссий."),
|
||||
contract("Визуальные профили", "Градиенты, размеры, фон, сетка и подписи."),
|
||||
ready("Ограничение памяти визуализатора", "Контроль объёма потоковых и записанных данных."),
|
||||
contract("Локальные предпочтения", "Тема, единицы и поведение панелей."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function rootById(id: RootId | null): RootDefinition | null {
|
||||
return id ? roots.find((root) => root.id === id) ?? null : null;
|
||||
}
|
||||
|
||||
export function workspaceById(id: string | null): WorkspaceDefinition | null {
|
||||
return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null;
|
||||
}
|
||||
|
||||
export function workspacesForRoot(root: RootId | null): WorkspaceDefinition[] {
|
||||
return root ? workspaces.filter((workspace) => workspace.root === root) : [];
|
||||
}
|
||||
|
||||
export const capabilityStatusLabel: Record<CapabilityStatus, string> = {
|
||||
active: "Работает",
|
||||
ready: "Готово к данным",
|
||||
contract: "Интерфейс готов",
|
||||
later: "Следующий этап",
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export type SceneProjection = "3d" | "2d" | "map";
|
||||
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
|
||||
export interface SceneSettings {
|
||||
projection: SceneProjection;
|
||||
pointSize: number;
|
||||
colorMode: PointColorMode;
|
||||
palette: PointPalette;
|
||||
customColor: string;
|
||||
accumulationSeconds: number;
|
||||
showPoints: boolean;
|
||||
showTrajectory: boolean;
|
||||
showGrid: boolean;
|
||||
showLabels: boolean;
|
||||
showCameraFrustums: boolean;
|
||||
}
|
||||
|
||||
export const defaultSceneSettings: SceneSettings = {
|
||||
projection: "3d",
|
||||
pointSize: 2.5,
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#35d7c1",
|
||||
accumulationSeconds: 12,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showGrid: true,
|
||||
showLabels: false,
|
||||
showCameraFrustums: true,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/shell.css";
|
||||
@import "./styles/workspaces.css";
|
||||
@import "./styles/spatial.css";
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
@@ -0,0 +1,388 @@
|
||||
:root {
|
||||
background: #050506;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
background: #050506;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root[data-nodedc-theme="dark"],
|
||||
body {
|
||||
--nodedc-accent-rgb: 247 248 244;
|
||||
--nodedc-on-accent-rgb: 8 8 10;
|
||||
--nodedc-focus-surface: rgb(255 255 255 / 0.09);
|
||||
--nodedc-primary-action-bg: rgb(247 248 244 / 0.96);
|
||||
--nodedc-primary-action-hover: rgb(255 255 255);
|
||||
--nodedc-primary-action-color: rgb(8 8 10 / 0.96);
|
||||
--nodedc-primary-action-shadow: none;
|
||||
--nodedc-modal-surface: rgb(15 15 17 / 0.97);
|
||||
--nodedc-floating-surface: rgb(15 15 17 / 0.98);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.control-station,
|
||||
body {
|
||||
--station-panel: #151517;
|
||||
--station-panel-soft: #0d0d0f;
|
||||
--station-stage: #08090b;
|
||||
--station-hairline: rgb(255 255 255 / 0.08);
|
||||
--station-hairline-strong: rgb(255 255 255 / 0.13);
|
||||
--station-accent-soft: rgb(255 255 255 / 0.045);
|
||||
--station-success-soft: rgb(255 255 255 / 0.045);
|
||||
--station-warning-soft: rgb(255 255 255 / 0.045);
|
||||
--station-danger-soft: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="accent"] {
|
||||
--nodedc-button-bg: var(--nodedc-primary-action-bg);
|
||||
--nodedc-button-color: var(--nodedc-primary-action-color);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="accent"]:hover:not(:disabled) {
|
||||
background: var(--nodedc-primary-action-hover);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="danger"] {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="danger"]:hover:not(:disabled) {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
}
|
||||
|
||||
.nodedc-status {
|
||||
min-height: auto;
|
||||
justify-content: flex-start;
|
||||
gap: 0.42rem;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
}
|
||||
|
||||
.nodedc-status::before {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
flex: 0 0 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.nodedc-status[data-tone="success"],
|
||||
.nodedc-status[data-tone="warning"],
|
||||
.nodedc-status[data-tone="danger"],
|
||||
.nodedc-status[data-tone="accent"] {
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-status[data-tone="success"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.nodedc-status[data-tone="warning"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.nodedc-status[data-tone="danger"]::before {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.nodedc-status[data-tone="accent"]::before {
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-inspector__section-trigger[data-open="true"],
|
||||
.nodedc-inspector__section-trigger[data-active="true"],
|
||||
.nodedc-inspector__section-trigger[data-tone="accent"] {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-description,
|
||||
.nodedc-inspector__section-trigger[data-active="true"] .nodedc-inspector__section-description,
|
||||
.nodedc-inspector__section-trigger[data-tone="accent"] .nodedc-inspector__section-description {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.nodedc-glass-material::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-header__avatar {
|
||||
background: rgb(226 226 226);
|
||||
}
|
||||
|
||||
.control-station .nodedc-application-panel__body {
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.section-eyebrow,
|
||||
.metric-card__eyebrow {
|
||||
display: block;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-heading h2,
|
||||
.panel-heading h3 {
|
||||
margin: 0.42rem 0 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.22rem;
|
||||
font-weight: 730;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.panel-heading--compact {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-lead {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.workspace-lead > div:first-child {
|
||||
min-width: 0;
|
||||
max-width: 62rem;
|
||||
}
|
||||
|
||||
.workspace-lead h2 {
|
||||
margin: 0.5rem 0 0.6rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(1.7rem, 3vw, 3rem);
|
||||
font-weight: 690;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workspace-lead p {
|
||||
max-width: 50rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.workspace-lead__note {
|
||||
max-width: 20rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.58rem 0.85rem;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.workspace-lead__status {
|
||||
display: grid;
|
||||
max-width: 22rem;
|
||||
justify-items: end;
|
||||
gap: 0.55rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 7rem;
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.metric-card[data-featured="true"] {
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.metric-card__reading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.36rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.metric-card__reading strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(1.4rem, 2vw, 2.2rem);
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-card__reading span,
|
||||
.metric-card p {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.metric-card p {
|
||||
margin: 0.6rem 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modal-stack,
|
||||
.inspector-control-stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-contract-note {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.8rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="accent"] {
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="warning"] {
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="success"] {
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note > svg {
|
||||
margin-top: 0.12rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.modal-contract-note strong,
|
||||
.modal-contract-note p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-contract-note strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.modal-contract-note p {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.source-format-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.source-format-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 1rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding: 0.7rem 0;
|
||||
}
|
||||
|
||||
.source-format-list code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.65rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-format-list span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.layer-inspector-intro {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layer-inspector-intro p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.69rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.layer-placeholder-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.layer-placeholder-list > span {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.layer-placeholder-list i {
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.layer-placeholder-list small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
.device-workspace__grid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
|
||||
align-items: start;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.device-workspace__side {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.connection-panel,
|
||||
.status-panel,
|
||||
.latency-panel,
|
||||
.session-panel {
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.error-banner__dot {
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
align-self: start;
|
||||
margin-top: 0.28rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.error-banner strong,
|
||||
.error-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-banner strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.error-banner p {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.wizard-list {
|
||||
display: grid;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: grid;
|
||||
grid-template-columns: 2.2rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wizard-step__rail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wizard-step__rail::after {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: var(--station-hairline);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.wizard-step:last-child .wizard-step__rail::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wizard-step__rail span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #27272a;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.59rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.wizard-step__content {
|
||||
min-width: 0;
|
||||
padding: 0.08rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.wizard-step:last-child .wizard-step__content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wizard-step__content > header {
|
||||
display: flex;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.wizard-step__content h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 710;
|
||||
}
|
||||
|
||||
.step-copy,
|
||||
.safety-note,
|
||||
.live-instruction {
|
||||
margin: 0 0 0.72rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.safety-note,
|
||||
.live-instruction {
|
||||
margin: 0.7rem 0 0;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
max-height: 20rem;
|
||||
gap: 0.48rem;
|
||||
margin-top: 0.72rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: grid;
|
||||
gap: 0.62rem;
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.72rem;
|
||||
}
|
||||
|
||||
.device-row[data-selected="true"] {
|
||||
background: rgb(255 255 255 / 0.065);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.device-row__identity,
|
||||
.device-row__action {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.device-row__identity {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.device-row__identity > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.device-row__name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.device-row__name small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.47rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.device-row__identity strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.69rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-row code,
|
||||
.detail-row code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-row__signal {
|
||||
width: 0.58rem;
|
||||
height: 0.58rem;
|
||||
flex: 0 0 0.58rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.device-row[data-compatible="true"] .device-row__signal {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.device-row__action > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.empty-device-list {
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.9rem;
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.field-stack,
|
||||
.session-form {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.connection-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0.72rem 0;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
padding: 0.62rem 0.75rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.connection-summary span {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.connection-summary strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-panel > .nodedc-segmented {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.session-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
|
||||
}
|
||||
|
||||
.session-form--replay > :first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.session-form--replay > .nodedc-button {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.session-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.session-footer p {
|
||||
max-width: 30rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
margin: 0.9rem 0 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
|
||||
gap: 1rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding: 0.7rem 0;
|
||||
}
|
||||
|
||||
.detail-row dt,
|
||||
.detail-row dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.detail-row dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.detail-row dd {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inline-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.inline-state::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.inline-state[data-state="open"]::before { background: rgb(var(--nodedc-success-rgb)); }
|
||||
.inline-state[data-state="error"]::before,
|
||||
.inline-state[data-state="closed"]::before { background: rgb(var(--nodedc-danger-rgb)); }
|
||||
|
||||
.latency-now {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 660;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.latency-now span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.latency-trace {
|
||||
display: flex;
|
||||
height: 7rem;
|
||||
align-items: end;
|
||||
gap: 0.2rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.018);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.latency-trace span {
|
||||
min-width: 0.16rem;
|
||||
flex: 1 1 0;
|
||||
border-radius: 999px 999px 0.12rem 0.12rem;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.latency-trace p {
|
||||
align-self: center;
|
||||
margin: auto;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.latency-legend {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
@media (max-width: 1480px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.device-workspace__grid {
|
||||
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.device-workspace__grid,
|
||||
.overview-grid,
|
||||
.mission-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pipeline-strip {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pipeline-strip > svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.map-inspector {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.45fr);
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.map-inspector > .section-eyebrow,
|
||||
.map-inspector > h3 {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.map-inspector .layer-summary-list {
|
||||
grid-row: 1 / 4;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.map-inspector > .nodedc-button {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: minmax(20rem, 1fr) repeat(2, minmax(10rem, 0.55fr));
|
||||
}
|
||||
|
||||
.camera-slot[data-primary="true"] {
|
||||
grid-row: 1;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.feature-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.capability-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-lead {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.workspace-lead__note,
|
||||
.workspace-lead__status {
|
||||
max-width: none;
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
width: 20rem;
|
||||
max-width: 36vw;
|
||||
}
|
||||
|
||||
.landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 7vw, 5.6rem);
|
||||
}
|
||||
|
||||
.scene-metrics {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.station-label,
|
||||
.control-station .nodedc-header__profile-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
top: 42%;
|
||||
right: 1.2rem;
|
||||
left: 1.2rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.landing-stage__copy h1 {
|
||||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
top: auto;
|
||||
right: 1rem;
|
||||
bottom: 3.3rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.landing-stage__footer {
|
||||
right: 1rem;
|
||||
bottom: 1.1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.landing-stage__footer span:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.metrics-grid,
|
||||
.capability-summary,
|
||||
.camera-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
grid-template-rows: repeat(4, minmax(14rem, 1fr));
|
||||
}
|
||||
|
||||
.camera-slot[data-primary="true"] {
|
||||
grid-row: auto;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.spatial-workspace {
|
||||
min-height: 38rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar,
|
||||
.spatial-toolbar__actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spatial-toolbar__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.spatial-toolbar__actions .nodedc-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.scene-adapter-note {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
width: auto;
|
||||
transform: translateY(4.8rem);
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.scene-timeline > .nodedc-button:first-child,
|
||||
.scene-timeline > code {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-inspector {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.timeline-ruler {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.timeline-lane {
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.timeline-lane small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-form--replay > :first-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.session-footer,
|
||||
.error-banner,
|
||||
.source-format-list > div {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.device-row__action {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.feature-row > .nodedc-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 6vw, 5rem);
|
||||
}
|
||||
|
||||
.landing-stage__copy p {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.landing-stage__actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
top: 1.2rem;
|
||||
right: 1.2rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.scene-tool-window {
|
||||
--scene-tool-window-top: 5.75rem;
|
||||
--scene-tool-window-right: 27rem;
|
||||
position: fixed;
|
||||
top: var(--scene-tool-window-top);
|
||||
right: var(--scene-tool-window-right);
|
||||
max-height: calc(100vh - var(--scene-tool-window-top) - 1.5rem);
|
||||
}
|
||||
|
||||
.scene-tool-window--display {
|
||||
--scene-tool-window-top: 7.75rem;
|
||||
--scene-tool-window-right: 28.5rem;
|
||||
}
|
||||
|
||||
.scene-tool-window--layers {
|
||||
--scene-tool-window-top: 9.75rem;
|
||||
--scene-tool-window-right: 30rem;
|
||||
}
|
||||
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 11.75rem;
|
||||
--scene-tool-window-right: 31.5rem;
|
||||
}
|
||||
|
||||
.nodedc-overlay:has(> .scene-tool-window[data-scene-active="true"]) {
|
||||
z-index: calc(var(--nodedc-layer-overlay) + 1);
|
||||
}
|
||||
|
||||
.nodedc-overlay[data-placement="center"] {
|
||||
z-index: calc(var(--nodedc-layer-overlay) + 2);
|
||||
}
|
||||
|
||||
.scene-window-state {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.scene-window-code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-field-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.72rem 0.8rem;
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scene-window-note {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.scene-tool-window,
|
||||
.scene-tool-window--display,
|
||||
.scene-tool-window--layers,
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 4.75rem;
|
||||
--scene-tool-window-right: 0.875rem;
|
||||
max-height: calc(100vh - 5.625rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 44.01rem) and (max-width: 70rem) {
|
||||
.scene-tool-window,
|
||||
.scene-tool-window--display,
|
||||
.scene-tool-window--layers,
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 13.5rem;
|
||||
--scene-tool-window-right: 1.5rem;
|
||||
max-height: calc(100vh - 15rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
.station-label {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.13em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.api-dot {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
flex: 0 0 0.48rem;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.api-dot[data-status="online"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.api-dot[data-status="checking"],
|
||||
.api-dot[data-status="degraded"] {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.api-dot[data-status="offline"] {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.landing-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--station-stage);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: clamp(2rem, 5vw, 6rem);
|
||||
width: min(39rem, 50%);
|
||||
transform: translateY(-55%);
|
||||
}
|
||||
|
||||
.landing-stage__copy h1 {
|
||||
margin: 0.75rem 0 1rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(3rem, 7vw, 7.6rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.072em;
|
||||
line-height: 0.87;
|
||||
}
|
||||
|
||||
.landing-stage__copy p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: clamp(0.8rem, 1vw, 1rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.landing-stage__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2.4rem;
|
||||
right: 2.4rem;
|
||||
display: grid;
|
||||
width: min(24rem, 30vw);
|
||||
gap: 0.6rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.055);
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(10 10 12 / 0.58);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.landing-stage__status > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.landing-stage__status p {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.landing-stage__footer {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 2.4rem;
|
||||
bottom: 1.8rem;
|
||||
left: 2.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
.spatial-workspace {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 34rem;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spatial-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__mode,
|
||||
.spatial-toolbar__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.spatial-viewport-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #06070a;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
}
|
||||
|
||||
.rerun-viewport,
|
||||
.rerun-viewport__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||
.rerun-viewport__canvas > div {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: 1rem;
|
||||
background: rgb(10 11 14 / 0.82);
|
||||
padding: 0.9rem 1rem;
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.rerun-viewport__notice strong,
|
||||
.rerun-viewport__notice span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice strong {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice span {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice--error {
|
||||
background: rgb(10 11 14 / 0.9);
|
||||
}
|
||||
|
||||
.busy-indicator {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 1rem;
|
||||
border: 2px solid rgb(255 255 255 / 0.12);
|
||||
border-top-color: var(--nodedc-text-primary);
|
||||
border-radius: 50%;
|
||||
animation: viewer-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes viewer-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-spatial-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__grid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spatial-axis {
|
||||
position: absolute;
|
||||
right: 2rem;
|
||||
bottom: 5.2rem;
|
||||
width: 4.4rem;
|
||||
height: 4.4rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.spatial-axis::before,
|
||||
.spatial-axis::after {
|
||||
position: absolute;
|
||||
bottom: 0.65rem;
|
||||
left: 0.75rem;
|
||||
width: 2.7rem;
|
||||
height: 1px;
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
content: "";
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.spatial-axis::before { transform: rotate(-24deg); }
|
||||
.spatial-axis::after { transform: rotate(-90deg); }
|
||||
.spatial-axis span { position: absolute; }
|
||||
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
|
||||
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
|
||||
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
|
||||
|
||||
.empty-spatial-stage__message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
max-width: 25rem;
|
||||
justify-items: center;
|
||||
gap: 0.62rem;
|
||||
transform: translate(-50%, -58%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__icon {
|
||||
display: grid;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
place-items: center;
|
||||
border: 1px solid var(--station-hairline-strong);
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.empty-spatial-stage__message strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__message p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.67rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scene-status,
|
||||
.scene-metrics,
|
||||
.scene-adapter-note,
|
||||
.scene-selection,
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 1px solid rgb(255 255 255 / 0.055);
|
||||
background: rgb(9 10 13 / 0.74);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.42rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.scene-status small {
|
||||
max-width: 18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.scene-metrics {
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
display: grid;
|
||||
min-width: 10rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.scene-metrics > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
padding: 0.38rem 0;
|
||||
}
|
||||
|
||||
.scene-metrics > div:last-child { border-bottom: 0; }
|
||||
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
|
||||
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
|
||||
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
|
||||
|
||||
.scene-adapter-note {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
max-width: 34rem;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border-radius: 0.9rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.75rem 0.9rem;
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.4;
|
||||
transform: translate(-50%, 5.2rem);
|
||||
}
|
||||
|
||||
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
|
||||
|
||||
.scene-selection {
|
||||
right: 0.85rem;
|
||||
bottom: 4.6rem;
|
||||
display: grid;
|
||||
max-width: 19rem;
|
||||
gap: 0.2rem;
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.scene-selection span,
|
||||
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
|
||||
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.scene-timeline {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.scene-timeline__track {
|
||||
height: 0.3rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.scene-timeline__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
|
||||
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
|
||||
|
||||
.scene-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.scene-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"] {
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.spatial-contract-strip {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
}
|
||||
|
||||
.spatial-contract-strip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.spatial-contract-strip i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
|
||||
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.busy-indicator { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
.standard-workspace,
|
||||
.device-workspace {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1rem;
|
||||
padding: 0 0 1rem;
|
||||
}
|
||||
|
||||
.feature-inventory {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.feature-group {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.6fr);
|
||||
gap: 1.6rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding-top: 1.2rem;
|
||||
}
|
||||
|
||||
.feature-group > header h3 {
|
||||
margin: 0.48rem 0 0.45rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.08rem;
|
||||
font-weight: 710;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.feature-group > header p {
|
||||
max-width: 25rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
padding: 0.78rem 0;
|
||||
}
|
||||
|
||||
.feature-row:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.feature-row__mark {
|
||||
width: 0.52rem;
|
||||
height: 0.52rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.feature-row[data-status="active"] .feature-row__mark {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.feature-row[data-status="ready"] .feature-row__mark {
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.feature-row[data-status="contract"] .feature-row__mark {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.feature-row strong,
|
||||
.feature-row p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-row strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 690;
|
||||
}
|
||||
|
||||
.feature-row p {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.capability-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.capability-summary > div {
|
||||
display: flex;
|
||||
min-height: 4.4rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--station-panel);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.capability-summary span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.capability-summary strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(19rem, 0.75fr);
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.contour-panel,
|
||||
.next-actions-panel,
|
||||
.timeline-panel,
|
||||
.mission-sequence,
|
||||
.mission-summary,
|
||||
.map-inspector {
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.pipeline-strip {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(7, auto);
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.3rem;
|
||||
}
|
||||
|
||||
.pipeline-strip > div {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 6rem;
|
||||
align-content: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.034);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.pipeline-strip > div::before {
|
||||
position: absolute;
|
||||
top: 0.7rem;
|
||||
right: 0.7rem;
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.pipeline-strip > div[data-state="ready"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.pipeline-strip > div[data-state="contract"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.pipeline-strip > svg {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.pipeline-strip span,
|
||||
.pipeline-strip small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pipeline-strip strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-list {
|
||||
display: grid;
|
||||
gap: 0.46rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.action-list > button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
outline: 0;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-list > button:hover,
|
||||
.action-list > button:focus-visible {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.action-list > button > span {
|
||||
display: grid;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.action-list strong,
|
||||
.action-list small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.action-list strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.action-list small {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.action-list > button > svg {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
min-height: 33rem;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(18rem, 0.72fr);
|
||||
grid-template-rows: repeat(3, minmax(9rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.camera-slot {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #090a0c;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
}
|
||||
|
||||
.camera-slot[data-primary="true"] {
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.camera-slot__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgb(255 255 255 / 0.012);
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
.camera-slot footer,
|
||||
.camera-slot__empty {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
.camera-slot footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.camera-slot header > span {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.camera-slot__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.camera-slot__empty span {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.camera-slot footer {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.camera-slot footer button {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.map-layout {
|
||||
display: grid;
|
||||
min-height: 34rem;
|
||||
grid-template-columns: minmax(0, 1fr) 18rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 30rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #0a0b0d;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
}
|
||||
|
||||
.map-canvas__grid,
|
||||
.map-canvas__contours {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.map-canvas__grid {
|
||||
background: rgb(255 255 255 / 0.012);
|
||||
}
|
||||
|
||||
.map-canvas__contours span {
|
||||
position: absolute;
|
||||
border: 1px solid rgb(255 255 255 / 0.055);
|
||||
border-radius: 48% 52% 62% 38%;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.map-canvas__contours span:nth-child(1) { inset: 16% 14% 20% 19%; }
|
||||
.map-canvas__contours span:nth-child(2) { inset: 23% 21% 27% 27%; }
|
||||
.map-canvas__contours span:nth-child(3) { inset: 31% 29% 35% 34%; }
|
||||
.map-canvas__contours span:nth-child(4) { inset: 39% 37% 43% 42%; }
|
||||
|
||||
.map-canvas svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-canvas svg path {
|
||||
fill: none;
|
||||
stroke: var(--nodedc-text-secondary);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.map-canvas svg circle {
|
||||
fill: #0a0b0d;
|
||||
stroke: var(--nodedc-text-secondary);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.map-point-label {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgb(8 9 11 / 0.86);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.34rem 0.55rem;
|
||||
font-size: 0.58rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.map-canvas__notice {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.map-inspector h3 {
|
||||
margin: 0.52rem 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.layer-summary-list {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layer-summary-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.15rem 0.55rem;
|
||||
border-radius: 0.8rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.layer-summary-list > div > span {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
grid-row: 1 / 3;
|
||||
align-self: center;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.layer-summary-list > div > span[data-tone="cyan"] { background: #35d7c1; }
|
||||
.layer-summary-list > div > span[data-tone="violet"] { background: var(--nodedc-text-primary); }
|
||||
.layer-summary-list > div > span[data-tone="green"] { background: #a6dc74; }
|
||||
.layer-summary-list > div > span[data-tone="orange"] { background: #f2af4c; }
|
||||
|
||||
.layer-summary-list strong { font-size: 0.68rem; }
|
||||
.layer-summary-list small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
|
||||
|
||||
.timeline-panel__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.timeline-panel__head h3 {
|
||||
margin: 0.48rem 0 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.timeline-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.timeline-panel__controls code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.timeline-ruler {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(11, minmax(0, 1fr));
|
||||
margin: 1.4rem 0 0.55rem 8.5rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.timeline-ruler span {
|
||||
border-left: 1px solid var(--station-hairline);
|
||||
padding-left: 0.2rem;
|
||||
}
|
||||
|
||||
.timeline-lanes {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.timeline-lane {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 7.5rem minmax(0, 1fr) 10rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding: 0.82rem 0;
|
||||
}
|
||||
|
||||
.timeline-lane strong,
|
||||
.timeline-lane small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-lane strong { font-size: 0.67rem; }
|
||||
.timeline-lane small { color: var(--nodedc-text-muted); font-size: 0.59rem; text-align: right; }
|
||||
|
||||
.timeline-lane > div {
|
||||
position: relative;
|
||||
height: 0.28rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.timeline-lane > div > span {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
display: none;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.timeline-lane[data-active="true"] > div > span { display: block; }
|
||||
|
||||
.mission-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(19rem, 0.65fr);
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.mission-steps {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.mission-steps button {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem;
|
||||
text-align: left;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mission-steps button > span {
|
||||
display: grid;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.mission-steps strong,
|
||||
.mission-steps small { display: block; }
|
||||
.mission-steps strong { color: var(--nodedc-text-primary); font-size: 0.7rem; }
|
||||
.mission-steps small { margin-top: 0.18rem; color: var(--nodedc-text-muted); font-size: 0.6rem; }
|
||||
.mission-steps svg { color: var(--nodedc-text-muted); }
|
||||
|
||||
.mission-summary {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.mission-readiness {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mission-readiness strong {
|
||||
font-size: 4rem;
|
||||
font-weight: 580;
|
||||
letter-spacing: -0.08em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mission-readiness span,
|
||||
.mission-summary p {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.mission-summary p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mission-summary__checks {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.mission-summary__checks span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.mission-summary__checks i {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
openEventSocket,
|
||||
type ConnectRequest,
|
||||
type ConsoleState,
|
||||
type EventSocketStatus,
|
||||
type LiveRequest,
|
||||
type ReplayRequest,
|
||||
type ViewerSettings,
|
||||
} from "./api";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
|
||||
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
||||
return error.status
|
||||
? `${message} (HTTP ${error.status})`
|
||||
: message;
|
||||
}
|
||||
return "Запрос к локальному сервису устройства завершился ошибкой.";
|
||||
}
|
||||
|
||||
function measuredLatency(state: ConsoleState | null): number | null {
|
||||
if (state?.source_mode !== "live") return null;
|
||||
const metrics = state?.metrics;
|
||||
if (!metrics) return null;
|
||||
|
||||
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
|
||||
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
|
||||
|
||||
const segments = [
|
||||
metrics.mqtt_to_decode_ms,
|
||||
metrics.publish_ms,
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
|
||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
export function useK1Console() {
|
||||
const [state, setState] = useState<ConsoleState | null>(null);
|
||||
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
|
||||
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const acceptState = useCallback((nextState: ConsoleState) => {
|
||||
setState(nextState);
|
||||
setBackendStatus("online");
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (reportErrors = true) => {
|
||||
const [healthResult, stateResult] = await Promise.allSettled([
|
||||
api.getHealth(),
|
||||
api.getState(),
|
||||
]);
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
if (stateResult.status === "fulfilled") {
|
||||
acceptState(stateResult.value);
|
||||
if (reportErrors) setError(null);
|
||||
}
|
||||
|
||||
if (healthResult.status === "fulfilled") {
|
||||
const health = healthResult.value;
|
||||
const healthy = health.ok !== false && health.status !== "error";
|
||||
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
|
||||
} else if (stateResult.status === "rejected") {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
|
||||
if (stateResult.status === "rejected" && reportErrors) {
|
||||
setError(messageFor(stateResult.reason));
|
||||
}
|
||||
}, [acceptState]);
|
||||
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextState = await operation();
|
||||
if (mounted.current) acceptState(nextState);
|
||||
return true;
|
||||
} catch (operationError) {
|
||||
if (mounted.current) {
|
||||
setError(messageFor(operationError));
|
||||
if (operationError instanceof ApiError && operationError.status === 0) {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted.current) setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[acceptState],
|
||||
);
|
||||
|
||||
const scan = useCallback(
|
||||
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
|
||||
[run],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
(request: ConnectRequest) => run("connect", () => api.connect(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startLive = useCallback(
|
||||
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startReplay = useCallback(
|
||||
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
() => run("stop", () => api.stopSession()),
|
||||
[run],
|
||||
);
|
||||
|
||||
const updateViewerSettings = useCallback(
|
||||
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh(true);
|
||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
let retry: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
dispose = openEventSocket(acceptState, (status) => {
|
||||
if (cancelled) return;
|
||||
setEventStatus(status);
|
||||
if ((status === "closed" || status === "error") && retry === undefined) {
|
||||
retry = window.setTimeout(() => {
|
||||
retry = undefined;
|
||||
connectEvents();
|
||||
}, 3_000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connectEvents();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retry !== undefined) window.clearTimeout(retry);
|
||||
dispose?.();
|
||||
};
|
||||
}, [acceptState]);
|
||||
|
||||
useEffect(() => {
|
||||
const latency = measuredLatency(state);
|
||||
if (latency === null) {
|
||||
setLatencyHistory((values) => (values.length ? [] : values));
|
||||
return;
|
||||
}
|
||||
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh: () => refresh(true),
|
||||
clearError: () => setError(null),
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
updateViewerSettings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { BleDevice } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import {
|
||||
backendLabel,
|
||||
backendTone,
|
||||
eventStatusLabel,
|
||||
finiteMetric,
|
||||
formatNumber,
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
pipelineLatency,
|
||||
sourceModeLabel,
|
||||
} from "../presentation";
|
||||
import type { useK1Console } from "../useK1Console";
|
||||
|
||||
type ConsoleController = ReturnType<typeof useK1Console>;
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Реальное устройство" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardStep({
|
||||
number,
|
||||
title,
|
||||
status,
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
tone?: StatusTone;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="wizard-step">
|
||||
<div className="wizard-step__rail" aria-hidden="true">
|
||||
<span>{number}</span>
|
||||
</div>
|
||||
<div className="wizard-step__content">
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
<StatusBadge tone={tone}>{status}</StatusBadge>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({
|
||||
device,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
device: BleDevice;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="device-row"
|
||||
data-compatible={device.likely_k1 ? "true" : undefined}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
>
|
||||
<div className="device-row__identity">
|
||||
<span className="device-row__signal" aria-hidden="true" />
|
||||
<div>
|
||||
<span className="device-row__name">
|
||||
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
||||
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
|
||||
</span>
|
||||
<code>{device.device_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="device-row__action">
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "primary" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{selected ? "Выбрано" : "Выбрать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyTrace({ values }: { values: number[] }) {
|
||||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<span
|
||||
key={`${index}-${value}`}
|
||||
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
|
||||
title={`${value.toFixed(1)} мс`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p>Измерений пока нет. График появится после получения реальных данных.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace({
|
||||
console,
|
||||
onOpenSpatialScene,
|
||||
}: {
|
||||
console: ConsoleController;
|
||||
onOpenSpatialScene: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh,
|
||||
clearError,
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
} = console;
|
||||
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
const [liveHost, setLiveHost] = useState("");
|
||||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedDeviceId &&
|
||||
state?.devices &&
|
||||
!state.devices.some((device) => device.device_id === selectedDeviceId)
|
||||
) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
|
||||
const sourceLabel = sourceModeLabel(state?.source_mode);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
});
|
||||
if (succeeded) setPassword("");
|
||||
};
|
||||
|
||||
const submitLive = async () => {
|
||||
const host = liveHost.trim();
|
||||
const started = await startLive(host ? { host } : {});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
const speed = Number(replaySpeed);
|
||||
const started = await startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="device-workspace">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||
Повторить
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
|
||||
<h2>Подключение первого устройства</h2>
|
||||
<p>
|
||||
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
|
||||
операторская модель от конкретного устройства не зависят.
|
||||
</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА КАДРОВ"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение адаптера"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Реальное число декодированных точек"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Исходные данные при этом сохраняются"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<GlassSurface className="connection-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||
<h2>Подключите устройство к сети</h2>
|
||||
</div>
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="wizard-list">
|
||||
<WizardStep
|
||||
number="01"
|
||||
title="Включите устройство"
|
||||
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
|
||||
tone={powerConfirmed ? "success" : "warning"}
|
||||
>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">
|
||||
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
|
||||
</span>
|
||||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="Устройство включено, индикатор стабилен"
|
||||
onChange={setPowerConfirmed}
|
||||
/>
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="02"
|
||||
title="Выберите Bluetooth-устройство"
|
||||
status={
|
||||
pendingAction === "scan"
|
||||
? "Поиск…"
|
||||
: selectedDeviceId
|
||||
? "Устройство выбрано"
|
||||
: `Найдено: ${devices.length}`
|
||||
}
|
||||
tone={
|
||||
pendingAction === "scan"
|
||||
? "accent"
|
||||
: selectedDeviceId
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
<p className="step-copy">
|
||||
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
|
||||
профиль — только подсказка; окончательный выбор всегда делает оператор.
|
||||
</p>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => {
|
||||
setSelectedDeviceId("");
|
||||
void scan();
|
||||
}}
|
||||
>
|
||||
{pendingAction === "scan"
|
||||
? "Сканируем Bluetooth — 6 секунд…"
|
||||
: "Показать все BLE-устройства"}
|
||||
</Button>
|
||||
<div className="device-list">
|
||||
{devices.length ? (
|
||||
devices.map((device) => (
|
||||
<DeviceRow
|
||||
key={device.device_id}
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={() => setSelectedDeviceId(device.device_id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-device-list">
|
||||
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
|
||||
повторите поиск.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Передайте настройки Wi‑Fi"
|
||||
status={state?.k1_ip ? "Подключено" : "Не подключено"}
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<TextField
|
||||
label="Название сети Wi‑Fi"
|
||||
hint="SSID"
|
||||
value={ssid}
|
||||
onChange={(event) => setSsid(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Сеть локального контура"
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль Wi‑Fi"
|
||||
hint="Только в оперативной памяти"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
<div className="connection-summary">
|
||||
<span>Устройство</span>
|
||||
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
variant="primary"
|
||||
icon={<Icon name="network" />}
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
>
|
||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||
</Button>
|
||||
<p className="safety-note">
|
||||
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
|
||||
удаляется из формы после успешного подключения.
|
||||
</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="device-workspace__side">
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||
</span>
|
||||
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
||||
{sourceLabel}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<SegmentedControl
|
||||
label="Источник данных"
|
||||
value={sessionIntent}
|
||||
items={sessionItems}
|
||||
onChange={setSessionIntent}
|
||||
/>
|
||||
|
||||
{sessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
hint="Обычно определяется автоматически"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !liveTargetReady}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||
</Button>
|
||||
<p className="live-instruction">
|
||||
{liveTargetReady
|
||||
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField
|
||||
label="Путь к записи"
|
||||
hint="Локальный файл исходных данных"
|
||||
value={replayPath}
|
||||
onChange={(event) => setReplayPath(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="sessions/.../capture.tsv"
|
||||
/>
|
||||
<TextField
|
||||
label="Скорость повтора"
|
||||
hint="Множитель"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={replaySpeed}
|
||||
onChange={(event) => setReplaySpeed(event.target.value)}
|
||||
/>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
|
||||
<Checker
|
||||
checked={replayLoop}
|
||||
label="Повторять по кругу"
|
||||
onChange={setReplayLoop}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
onClick={() => void submitReplay()}
|
||||
>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="session-footer">
|
||||
<p>Статус изменится только после ответа локального сервиса.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="diagnostics-grid">
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
|
||||
<h2>Локальный контур</h2>
|
||||
</div>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</header>
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Канал событий">
|
||||
<span className="inline-state" data-state={eventStatus}>
|
||||
{eventStatusLabel(eventStatus)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="Адрес устройства">
|
||||
<code>{state?.k1_ip || "Не получен"}</code>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="latency-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||
<h2>Время до публикации</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>мс</span>
|
||||
</strong>
|
||||
</header>
|
||||
<LatencyTrace values={latencyHistory} />
|
||||
<div className="latency-legend">
|
||||
<span>Старые</span>
|
||||
<span>Последние</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ConsoleState } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import {
|
||||
RerunViewport,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
} from "../components/RerunViewport";
|
||||
import {
|
||||
capabilityStatusLabel,
|
||||
type CapabilityStatus,
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
if (status === "contract") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||
return (
|
||||
<div className="feature-inventory">
|
||||
{definition.groups.map((group) => (
|
||||
<section className="feature-group" key={group.title}>
|
||||
<header>
|
||||
<span className="section-eyebrow">ФУНКЦИОНАЛЬНЫЙ БЛОК</span>
|
||||
<h3>{group.title}</h3>
|
||||
<p>{group.description}</p>
|
||||
</header>
|
||||
<div className="feature-list">
|
||||
{group.capabilities.map((capability) => (
|
||||
<div className="feature-row" key={capability.label} data-status={capability.status}>
|
||||
<span className="feature-row__mark" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{capability.label}</strong>
|
||||
<p>{capability.description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone(capability.status)}>
|
||||
{capabilityStatusLabel[capability.status]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||
return (
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||
<h2>{definition.title}</h2>
|
||||
<p>{definition.description}</p>
|
||||
</div>
|
||||
{note ? <span className="workspace-lead__note">{note}</span> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: ConsoleState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
sceneSettings: SceneSettings;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
||||
function OverviewWorkspace({
|
||||
definition,
|
||||
state,
|
||||
backendStatus,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const adapterOnline = backendStatus === "online";
|
||||
const rerunReady = Boolean(state?.rerun_grpc_url);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace overview-workspace">
|
||||
<WorkspaceLead
|
||||
definition={definition}
|
||||
note="Числа появляются только из реального локального контура"
|
||||
/>
|
||||
<section className="metrics-grid" aria-label="Оперативные показатели">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение потока"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Без синтетического заполнения"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="РЕЖИМ"
|
||||
value={sourceModeLabel(state?.source_mode)}
|
||||
detail="Реальное время, повтор или ожидание"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="overview-grid">
|
||||
<GlassSurface className="contour-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
|
||||
<h2>Контур наблюдения</h2>
|
||||
</div>
|
||||
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
|
||||
{adapterOnline ? "Контур доступен" : "Нет связи"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="pipeline-strip" aria-label="Путь данных">
|
||||
<div data-state={state?.k1_ip ? "ready" : "idle"}>
|
||||
<span>01</span>
|
||||
<strong>Устройство</strong>
|
||||
<small>{state?.k1_ip || "не назначено"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={streamActive ? "ready" : "idle"}>
|
||||
<span>02</span>
|
||||
<strong>Адаптер</strong>
|
||||
<small>{sourceModeLabel(state?.source_mode)}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={rerunReady ? "ready" : "idle"}>
|
||||
<span>03</span>
|
||||
<strong>Rerun</strong>
|
||||
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state="ready">
|
||||
<span>04</span>
|
||||
<strong>Оператор</strong>
|
||||
<small>интерфейс готов</small>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="next-actions-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
|
||||
<h2>Продолжить работу</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="action-list">
|
||||
<button type="button" onClick={() => navigation.openView("local-device")}>
|
||||
<span><Icon name="network" /></span>
|
||||
<div>
|
||||
<strong>Подключить устройство</strong>
|
||||
<small>Bluetooth, Wi‑Fi и запуск потока</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigation.openView("spatial-scene")}>
|
||||
<span><Icon name="globe" /></span>
|
||||
<div>
|
||||
<strong>Открыть пространственную сцену</strong>
|
||||
<small>Облако точек, траектория и слои</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigation.openView("streams")}>
|
||||
<span><Icon name="activity" /></span>
|
||||
<div>
|
||||
<strong>Проверить потоки</strong>
|
||||
<small>Транспорт, схемы и готовность</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
<div className="empty-spatial-stage__grid" aria-hidden="true" />
|
||||
<div className="spatial-axis" aria-hidden="true">
|
||||
<span data-axis="x">X</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
|
||||
<strong>Визуальный источник не назначен</strong>
|
||||
<p>
|
||||
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
|
||||
открыть реальные данные.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
sceneSettings,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
}, []);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[viewerStatus];
|
||||
|
||||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||
|
||||
return (
|
||||
<div className="spatial-workspace">
|
||||
<div className="spatial-toolbar">
|
||||
<div className="spatial-toolbar__mode">
|
||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||
</div>
|
||||
<div className="spatial-toolbar__actions">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
Источник
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||
Слои
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
||||
Отображение
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spatial-viewport-shell">
|
||||
{sourceUrl.trim() ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
<div className="scene-status scene-status--top-left">
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
|
||||
<div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Точек</span>
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="scene-timeline">
|
||||
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
|
||||
Воспроизвести
|
||||
</Button>
|
||||
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
|
||||
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
|
||||
</div>
|
||||
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
|
||||
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
|
||||
ЭФИР
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state="contract" />Объекты / маски</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace">
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
<div className="camera-grid">
|
||||
{slots.map((slot, index) => (
|
||||
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
|
||||
<div className="camera-slot__grid" aria-hidden="true" />
|
||||
<header>
|
||||
<span>{slot}</span>
|
||||
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
|
||||
</header>
|
||||
<div className="camera-slot__empty">
|
||||
<Icon name={index === 3 ? "image" : "video"} />
|
||||
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
|
||||
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
|
||||
</footer>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const isMissionRoute = definition.id === "routes";
|
||||
return (
|
||||
<div className="standard-workspace map-workspace">
|
||||
<WorkspaceLead
|
||||
definition={definition}
|
||||
note={isMissionRoute ? "Схема интерфейса — координаты и командный канал не подключены" : "Картографический модуль не подключён"}
|
||||
/>
|
||||
<div className="map-layout">
|
||||
<div className="map-canvas" data-route={isMissionRoute ? "true" : undefined}>
|
||||
<div className="map-canvas__grid" aria-hidden="true" />
|
||||
<div className="map-canvas__contours" aria-hidden="true">
|
||||
<span /><span /><span /><span />
|
||||
</div>
|
||||
<svg viewBox="0 0 1000 560" role="img" aria-label="Схематический черновик маршрута без географических координат">
|
||||
<path d="M165 420 C 235 350, 272 375, 340 292 S 480 235, 545 290 S 680 340, 765 210 S 845 125, 892 155" />
|
||||
<circle cx="165" cy="420" r="8" />
|
||||
<circle cx="340" cy="292" r="8" />
|
||||
<circle cx="545" cy="290" r="8" />
|
||||
<circle cx="765" cy="210" r="8" />
|
||||
<circle cx="892" cy="155" r="8" />
|
||||
</svg>
|
||||
<div className="map-point-label" style={{ left: "15%", top: "72%" }}>A · старт</div>
|
||||
<div className="map-point-label" style={{ left: "52%", top: "49%" }}>C · наблюдение</div>
|
||||
<div className="map-point-label" style={{ left: "84%", top: "22%" }}>E · финиш</div>
|
||||
<div className="map-canvas__notice">
|
||||
<StatusBadge tone="warning">Схема интерфейса</StatusBadge>
|
||||
<span>Не является картой местности и не содержит реальных координат.</span>
|
||||
</div>
|
||||
</div>
|
||||
<GlassSurface className="map-inspector" padding="lg">
|
||||
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
|
||||
<h3>Рабочая композиция</h3>
|
||||
<div className="layer-summary-list">
|
||||
<div><span data-tone="cyan" /><strong>Маршрут</strong><small>5 точек · схема</small></div>
|
||||
<div><span data-tone="violet" /><strong>Геозоны</strong><small>не назначены</small></div>
|
||||
<div><span data-tone="green" /><strong>Высота</strong><small>контракт GridMap</small></div>
|
||||
<div><span data-tone="orange" /><strong>Проходимость</strong><small>обработчик не подключён</small></div>
|
||||
</div>
|
||||
<Button variant="secondary" width="full" disabled>Редактировать точки</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
|
||||
const rows = useMemo(() => [
|
||||
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) },
|
||||
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) },
|
||||
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
|
||||
{ label: "Камеры", value: "каналы не назначены", active: false },
|
||||
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
|
||||
], [state]);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace timeline-workspace">
|
||||
<WorkspaceLead definition={definition} note="Отображаются только доступные состояния" />
|
||||
<GlassSurface className="timeline-panel" padding="lg">
|
||||
<header className="timeline-panel__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">СИНХРОННЫЕ КАНАЛЫ</span>
|
||||
<h3>Текущий интервал</h3>
|
||||
</div>
|
||||
<div className="timeline-panel__controls">
|
||||
<Button size="compact" variant="secondary" disabled>Воспроизвести</Button>
|
||||
<code>—:—:—.———</code>
|
||||
</div>
|
||||
</header>
|
||||
<div className="timeline-ruler" aria-hidden="true">
|
||||
{Array.from({ length: 11 }, (_, index) => <span key={index}>{index * 10}</span>)}
|
||||
</div>
|
||||
<div className="timeline-lanes">
|
||||
{rows.map((row, index) => (
|
||||
<div className="timeline-lane" key={row.label} data-active={row.active ? "true" : undefined}>
|
||||
<strong>{row.label}</strong>
|
||||
<div><span style={{ left: row.active ? `${18 + index * 9}%` : "0" }} /></div>
|
||||
<small>{row.value}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const steps = [
|
||||
{ id: "01", label: "Аппарат", value: "Не назначен" },
|
||||
{ id: "02", label: "Зона", value: "Не задана" },
|
||||
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
|
||||
{ id: "04", label: "Наблюдение", value: "Облако точек" },
|
||||
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
|
||||
];
|
||||
return (
|
||||
<div className="standard-workspace mission-workspace">
|
||||
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
|
||||
<div className="mission-layout">
|
||||
<GlassSurface className="mission-sequence" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
|
||||
<h2>Новая миссия</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
</header>
|
||||
<div className="mission-steps">
|
||||
{steps.map((step) => (
|
||||
<button key={step.id} type="button" disabled>
|
||||
<span>{step.id}</span>
|
||||
<div><strong>{step.label}</strong><small>{step.value}</small></div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<GlassSurface className="mission-summary" padding="lg">
|
||||
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
|
||||
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
|
||||
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
|
||||
<div className="mission-summary__checks">
|
||||
<span><i />Аппарат</span>
|
||||
<span><i />Геометрия</span>
|
||||
<span><i />Связь</span>
|
||||
<span><i />Безопасность</span>
|
||||
</div>
|
||||
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
|
||||
const active = definition.groups.reduce(
|
||||
(count, group) => count + group.capabilities.filter((capability) => capability.status === "active").length,
|
||||
0,
|
||||
);
|
||||
const ready = definition.groups.reduce(
|
||||
(count, group) => count + group.capabilities.filter((capability) => capability.status === "ready").length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace catalog-workspace">
|
||||
<WorkspaceLead definition={definition} note="Архитектурный и интерфейсный контракт" />
|
||||
<section className="capability-summary" aria-label="Готовность функционала">
|
||||
<div><span>Всего функций</span><strong>{total}</strong></div>
|
||||
<div><span>Работает сейчас</span><strong>{active}</strong></div>
|
||||
<div><span>Готово к данным</span><strong>{ready}</strong></div>
|
||||
<div><span>Без серверной логики</span><strong>{Math.max(0, total - active - ready)}</strong></div>
|
||||
</section>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
switch (props.definition.kind) {
|
||||
case "overview":
|
||||
return <OverviewWorkspace {...props} />;
|
||||
case "spatial":
|
||||
return <SpatialWorkspace {...props} />;
|
||||
case "cameras":
|
||||
return <CamerasWorkspace {...props} />;
|
||||
case "map":
|
||||
return <MapWorkspace {...props} />;
|
||||
case "timeline":
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
||||
|
||||
return {
|
||||
plugins: [react(), wasm()],
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: "127.0.0.1",
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user