chore: rename repository to NODEDC MISSION CORE

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 12:10:05 +03:00
parent 8459bb03fb
commit 9225227421
50 changed files with 220 additions and 61 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_TARGET=http://127.0.0.1:8000
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
+256
View File
@@ -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.512.0`, режимы
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
Plasma, grayscale и custom, накопление `0120` секунд, а также видимость облака,
траектории и сетки. Значение по умолчанию — 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
и безопасная документация.
+17
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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

+766
View File
@@ -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>
</>
);
}
+224
View File
@@ -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>
);
}
+25
View File
@@ -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>,
);
+30
View File
@@ -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;
}
+89
View File
@@ -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 "Неизвестно";
}
+758
View File
@@ -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: "Следующий этап",
};
+31
View File
@@ -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,
};
+6
View File
@@ -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";
+388
View File
@@ -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;
}
+416
View File
@@ -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);
}
}
+116
View File
@@ -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;
}
+352
View File
@@ -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);
}
+203
View File
@@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</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="Пароль WiFi"
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" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</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, WiFi и запуск потока</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;
}
}
+24
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+16
View File
@@ -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"]
}
+32
View File
@@ -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,
},
};
});