feat: activate self-hosted lidar spatial scene
This commit is contained in:
+76
-44
@@ -7,8 +7,9 @@ device adapter, но структура интерфейса от него не
|
||||
|
||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||
переход во внешний облачный интерфейс. Сам браузерный компонент, однако, должен
|
||||
получить совместимый источник RRD или Rerun gRPC.
|
||||
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
|
||||
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
|
||||
потока или совместимой RRD-записи.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
@@ -18,10 +19,10 @@ device adapter, но структура интерфейса от него не
|
||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
| Legacy Foxglove adapter | Реализован | Текущий Python runtime публикует декодированные K1 данные в локальный Foxglove WebSocket. Это проверенный диагностический backend, а не продуктовый UI. |
|
||||
| Встроенный Rerun Viewer | Реализован | Открывает назначенный RRD по HTTP(S) или совместимый Rerun gRPC/proxy source внутри Control Station. |
|
||||
| Автоматический MQTT → Rerun | **Не реализован** | Запуск K1 live/replay пока не создаёт RRD и не подаёт данные во встроенный Rerun viewport. Нужен отдельный stream adapter. |
|
||||
| Контролы сцены → Rerun Blueprint | **Не реализованы** | Размер точек, палитра, накопление, слои, проекция, timeline и компоновка пока являются честным UI-контрактом и состоянием текущего сеанса. |
|
||||
| Автоматический 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. |
|
||||
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
|
||||
|
||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||
@@ -31,28 +32,30 @@ device adapter, но структура интерфейса от него не
|
||||
## Архитектура данных
|
||||
|
||||
```text
|
||||
NODE.DC Control Station в браузере
|
||||
├── control plane
|
||||
│ └── REST /api/* + WebSocket /api/events
|
||||
│ └── FastAPI на 127.0.0.1:8000
|
||||
│ ├── CoreBluetooth: discovery и reviewed Wi-Fi provisioning
|
||||
│ └── live/replay session runtime
|
||||
│
|
||||
├── текущий K1 preview path — legacy adapter
|
||||
│ └── K1 MQTT :1883, read-only
|
||||
│ ├── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
||||
│ └── protobuf/LZ4 decoder
|
||||
│ └── Foxglove bridge → локальный WebSocket
|
||||
│
|
||||
└── продуктовая пространственная сцена
|
||||
└── встроенный @rerun-io/web-viewer
|
||||
└── RRD URL или Rerun gRPC/proxy source
|
||||
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
|
||||
└── пространственная сцена NODE.DC
|
||||
|
||||
React Control Station ←→ REST /api/* + WebSocket /api/events
|
||||
FastAPI на 127.0.0.1:8000
|
||||
CoreBluetooth + live/replay runtime
|
||||
```
|
||||
|
||||
Между декодером K1 и Rerun Viewer сейчас нет автоматической стрелки. Поля
|
||||
`foxglove_ws_url` и `foxglove_viewer_url` ещё присутствуют в API только потому,
|
||||
что Python runtime продолжает поднимать проверенный legacy adapter. Пункт
|
||||
управления не использует их как основную поверхность визуализации.
|
||||
В момент готовности `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 держит их пустыми.
|
||||
|
||||
## Фиксированная оболочка
|
||||
|
||||
@@ -70,8 +73,10 @@ Shell собран из локальных NODE.DC UI packages и сохраня
|
||||
|
||||
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
||||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||
Control Station. Это ещё не означает, что все продуктовые контролы уже связаны
|
||||
с API Rerun: текущая граница явно показана статусом `Интерфейс готов`.
|
||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
||||
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
|
||||
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
|
||||
layout пока остаются интерфейсным контрактом.
|
||||
|
||||
## Архитектурные разделы
|
||||
|
||||
@@ -93,7 +98,7 @@ Control Station. Это ещё не означает, что все продук
|
||||
|
||||
## Установка, проверка, сборка и запуск
|
||||
|
||||
Требуются Node.js 20 или новее, `uv` и соседний checkout
|
||||
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
|
||||
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
|
||||
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
||||
|
||||
@@ -142,18 +147,22 @@ Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/
|
||||
6. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
|
||||
останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
7. Состояние, частота, число точек, задержка и пропуски preview появятся только
|
||||
после прихода реальных данных.
|
||||
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
частота, число точек, задержка и пропуски preview появятся после прихода
|
||||
сообщений K1.
|
||||
|
||||
Этот путь запускает существующий MQTT/Foxglove runtime. Чтобы увидеть тот же
|
||||
живой K1 поток во встроенной пространственной сцене, нужно сначала реализовать и
|
||||
запустить MQTT→Rerun stream adapter; одного запуска live-сессии сейчас
|
||||
недостаточно.
|
||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
||||
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
|
||||
0 ошибок декодирования.
|
||||
|
||||
## Источники Rerun
|
||||
|
||||
В **Наблюдение → Пространственная сцена → Источник** принимается URL, который
|
||||
понимает `@rerun-io/web-viewer` версии, зафиксированной в `package.json`:
|
||||
Live/replay runtime автоматически назначает первый URL из примера. В
|
||||
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
|
||||
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
|
||||
зафиксированной в `package.json` версии:
|
||||
|
||||
```text
|
||||
rerun+http://127.0.0.1:9876/proxy
|
||||
@@ -165,14 +174,23 @@ https://example.internal/recording.rrd
|
||||
proxy;
|
||||
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
|
||||
- версия RRD должна быть совместима с версией Web Viewer;
|
||||
- URL хранится только в состоянии текущей страницы;
|
||||
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
|
||||
автоматическим source;
|
||||
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||
|
||||
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||
оболочку. Продуктовые настройки отображения, custom timeline, переключатель
|
||||
2D/3D/карты и сохранение RBL пока не вызывают Blueprint/playback API. Черновик
|
||||
компоновки фиксируется только в памяти текущей страницы и не записывается на
|
||||
диск.
|
||||
оболочку. Backend принимает и применяет к Rerun размер точки `0.5–12.0`, режимы
|
||||
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
|
||||
Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака,
|
||||
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
|
||||
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
|
||||
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
|
||||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
||||
а не готовую семантическую классификацию.
|
||||
|
||||
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
|
||||
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
|
||||
в памяти текущей страницы и не записывается на диск.
|
||||
|
||||
## Контракт локального API
|
||||
|
||||
@@ -185,12 +203,14 @@ https://example.internal/recording.rrd
|
||||
| `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` и legacy-поля
|
||||
`foxglove_ws_url`/`foxglove_viewer_url`. OpenAPI доступен по `/api/docs`.
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
|
||||
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
|
||||
пустыми. OpenAPI доступен по `/api/docs`.
|
||||
|
||||
## Карта исходников frontend
|
||||
|
||||
@@ -211,6 +231,12 @@ https://example.internal/recording.rrd
|
||||
## Safety и чувствительные данные
|
||||
|
||||
- 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-запись;
|
||||
@@ -219,6 +245,12 @@ https://example.internal/recording.rrd
|
||||
физического сканирования остаются за кнопкой 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-сцена содержит только облако точек и позу/траекторию, а
|
||||
операционные метрики отображаются внешней оболочкой Control Station.
|
||||
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
|
||||
траекторию и карту помещения. В репозиторий попадают только redacted manifests
|
||||
и безопасная документация.
|
||||
|
||||
+357
-171
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderAvatar,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import type { ViewerSettings } from "./api";
|
||||
import {
|
||||
rootById,
|
||||
roots,
|
||||
@@ -44,6 +45,9 @@ import {
|
||||
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: "Значение отражённого сигнала" },
|
||||
@@ -61,6 +65,46 @@ const paletteOptions: Array<{ value: PointPalette; label: string; description: s
|
||||
{ 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>({
|
||||
@@ -74,6 +118,7 @@ export default function App() {
|
||||
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);
|
||||
@@ -83,6 +128,48 @@ export default function App() {
|
||||
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);
|
||||
@@ -100,11 +187,41 @@ export default function App() {
|
||||
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[]>(() => {
|
||||
@@ -122,17 +239,14 @@ export default function App() {
|
||||
actions.push(
|
||||
{ label: "Настроить источник", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||
{ label: "Открыть слои", icon: "list", onClick: () => setLayerInspectorOpen(true) },
|
||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
onClick: () => {
|
||||
setLayoutDraftSaved(false);
|
||||
setLayoutWindowOpen(true);
|
||||
},
|
||||
onClick: openLayout,
|
||||
});
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
|
||||
@@ -231,8 +345,8 @@ export default function App() {
|
||||
{phaseLabel(console.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<StatusBadge tone={sourceUrl ? "accent" : "warning"}>
|
||||
{sourceUrl ? "Источник назначен" : "Без источника"}
|
||||
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
@@ -242,20 +356,22 @@ export default function App() {
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace console={console} />
|
||||
<DeviceWorkspace
|
||||
console={console}
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
state={console.state}
|
||||
backendStatus={console.backendStatus}
|
||||
sourceUrl={sourceUrl}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
sceneSettings={sceneSettings}
|
||||
onSceneSettingsChange={setSceneSettings}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
openDisplay,
|
||||
openLayers: () => setLayerInspectorOpen(true),
|
||||
openLayers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -265,10 +381,19 @@ export default function App() {
|
||||
|
||||
<Window
|
||||
open={sourceWindowOpen}
|
||||
title="Источник визуальных данных"
|
||||
subtitle="ИСТОЧНИКИ RRD И RERUN GRPC"
|
||||
size="md"
|
||||
onClose={() => setSourceWindowOpen(false)}
|
||||
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
|
||||
@@ -276,10 +401,9 @@ export default function App() {
|
||||
onClick={() => {
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setSourceWindowOpen(false);
|
||||
}}
|
||||
>
|
||||
Очистить источник
|
||||
Сбросить адрес
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -287,48 +411,107 @@ export default function App() {
|
||||
disabled={!sourceDraft.trim()}
|
||||
onClick={() => {
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setSourceWindowOpen(false);
|
||||
}}
|
||||
>
|
||||
Подключить
|
||||
Применить адрес
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<div className="modal-contract-note" data-tone="accent">
|
||||
<Icon name="network" />
|
||||
<div>
|
||||
<strong>Встроенный открытый визуализатор</strong>
|
||||
<p>
|
||||
Источник открывается внутри интерфейса, без перехода во внешний сервис. Версия
|
||||
RRD должна совпадать с версией визуального движка.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
label="Адрес источника"
|
||||
hint="URL"
|
||||
value={sourceDraft}
|
||||
onChange={(event) => setSourceDraft(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="rerun+http://127.0.0.1:9876/proxy или https://…/recording.rrd"
|
||||
description="Текущий локальный MQTT-поток пока требует отдельного адаптера потока Rerun."
|
||||
/>
|
||||
<div className="source-format-list">
|
||||
<div><code>rerun+http://…/proxy</code><span>Живой gRPC источник</span></div>
|
||||
<div><code>http://…/recording.rrd</code><span>Запись по HTTP</span></div>
|
||||
<div><code>https://…/recording.rrd</code><span>Удалённая запись</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<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="ПРОСТРАНСТВЕННАЯ СЦЕНА"
|
||||
size="md"
|
||||
onClose={() => setDisplayWindowOpen(false)}
|
||||
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)}>
|
||||
@@ -337,143 +520,138 @@ export default function App() {
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
setSceneSettings(displayDraft);
|
||||
setDisplayWindowOpen(false);
|
||||
}}
|
||||
disabled={console.pendingAction === "viewer"}
|
||||
onClick={() => void applyDisplaySettings()}
|
||||
>
|
||||
Применить к профилю
|
||||
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<div className="modal-contract-note" data-tone="warning">
|
||||
<Icon name="alert" />
|
||||
<div>
|
||||
<strong>Профиль визуализации подготовлен</strong>
|
||||
<p>
|
||||
Параметры уже собраны в продуктовой форме. Они начнут управлять компоновкой Rerun
|
||||
после подключения адаптера потока и профиля; сейчас меняется только интерфейсный черновик.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["points", "history", "scene"]}
|
||||
sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер, атрибут и градиент",
|
||||
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
|
||||
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 }))}
|
||||
<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 label="Атрибут цвета" layout="stack">
|
||||
<Select
|
||||
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>
|
||||
<ControlRow label="Палитра" layout="stack">
|
||||
<Select
|
||||
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: "История облака и траектория",
|
||||
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
|
||||
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 }))}
|
||||
/>
|
||||
<Switch
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scene",
|
||||
label: "Сцена",
|
||||
description: "Сетка, подписи и области обзора камер",
|
||||
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Switch checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
|
||||
<Switch checked={displayDraft.showLabels} label="Подписи сущностей" onChange={(showLabels) => setDisplayDraft((current) => ({ ...current, showLabels }))} />
|
||||
<Switch checked={displayDraft.showCameraFrustums} label="Области обзора камер в 3D" onChange={(showCameraFrustums) => setDisplayDraft((current) => ({ ...current, showCameraFrustums }))} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : 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 }))}
|
||||
/>
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
description="Линия пути устройства в координатах сцены"
|
||||
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scene",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
|
||||
<Checker checked={false} disabled label="Подписи сущностей" description="Появятся после подключения семантических сущностей" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layerInspectorOpen}
|
||||
title="Слои сцены"
|
||||
subtitle="ДЕРЕВО СУЩНОСТЕЙ / ПЕРЕОПРЕДЕЛЕНИЯ"
|
||||
subtitle="Сущности пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
onClose={() => setLayerInspectorOpen(false)}
|
||||
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">
|
||||
<StatusBadge tone={sourceUrl ? "accent" : "neutral"}>
|
||||
{sourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
<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", "frames", "perception"]}
|
||||
defaultOpen={["geometry"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "geometry",
|
||||
label: "Геометрия",
|
||||
description: "Облако точек и путь",
|
||||
group: "СЦЕНА",
|
||||
tone: "accent",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Switch checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => setSceneSettings((current) => ({ ...current, showPoints }))} />
|
||||
<Switch checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => setSceneSettings((current) => ({ ...current, showTrajectory }))} />
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" description="Основной поток геометрии лидара" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" description="Положение и ориентация устройства во времени" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -481,11 +659,10 @@ export default function App() {
|
||||
id: "frames",
|
||||
label: "Координаты и камеры",
|
||||
description: "Преобразования и области обзора",
|
||||
group: "СЦЕНА",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Switch checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => setSceneSettings((current) => ({ ...current, showGrid }))} />
|
||||
<Switch checked={sceneSettings.showCameraFrustums} label="Области обзора камер в 3D" onChange={(showCameraFrustums) => setSceneSettings((current) => ({ ...current, showCameraFrustums }))} />
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
||||
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -493,12 +670,11 @@ export default function App() {
|
||||
id: "perception",
|
||||
label: "Объекты и маски",
|
||||
description: "Ожидают внешние обработчики",
|
||||
group: "СЦЕНА",
|
||||
content: (
|
||||
<div className="layer-placeholder-list">
|
||||
<span><i />Рамки 2D / 3D<small>не подключено</small></span>
|
||||
<span><i />Маски сегментации<small>не подключено</small></span>
|
||||
<span><i />Ключевые точки / треки<small>не подключено</small></span>
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={false} disabled label="Рамки 2D и 3D" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Маски сегментации" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Ключевые точки и треки" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -511,10 +687,20 @@ export default function App() {
|
||||
title="Компоновка рабочей области"
|
||||
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
|
||||
size="sm"
|
||||
onClose={() => setLayoutWindowOpen(false)}
|
||||
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={() => setLayoutWindowOpen(false)}>Закрыть</Button>
|
||||
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
|
||||
@@ -30,10 +30,23 @@ export interface ConsoleState {
|
||||
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;
|
||||
@@ -174,6 +187,10 @@ export const api = {
|
||||
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";
|
||||
|
||||
@@ -23,19 +23,6 @@ export function LandingStage({
|
||||
}: LandingStageProps) {
|
||||
return (
|
||||
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
||||
<div className="landing-stage__field" aria-hidden="true">
|
||||
<span className="landing-stage__grid" />
|
||||
<span className="landing-stage__orbit landing-stage__orbit--one" />
|
||||
<span className="landing-stage__orbit landing-stage__orbit--two" />
|
||||
<span className="landing-stage__orbit landing-stage__orbit--three" />
|
||||
<span className="landing-stage__sweep" />
|
||||
<span className="landing-stage__node landing-stage__node--one" />
|
||||
<span className="landing-stage__node landing-stage__node--two" />
|
||||
<span className="landing-stage__node landing-stage__node--three" />
|
||||
<span className="landing-stage__node landing-stage__node--four" />
|
||||
</div>
|
||||
<div className="landing-stage__shade" />
|
||||
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span>
|
||||
<h1>{root?.title ?? "Пункт управления"}</h1>
|
||||
@@ -44,7 +31,7 @@ export function LandingStage({
|
||||
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
|
||||
</p>
|
||||
<div className="landing-stage__actions">
|
||||
<Button variant="accent" icon={<Icon name="globe" />} onClick={onOpenObservation}>
|
||||
<Button variant="primary" icon={<Icon name="globe" />} onClick={onOpenObservation}>
|
||||
Пространственная сцена
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
|
||||
|
||||
@@ -32,7 +32,6 @@ export function RerunViewport({
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let started = false;
|
||||
let stopViewer: (() => void) | undefined;
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
setStatus("loading");
|
||||
@@ -41,7 +40,17 @@ export function RerunViewport({
|
||||
void import("@rerun-io/web-viewer")
|
||||
.then(async ({ WebViewer }) => {
|
||||
const viewer = new WebViewer();
|
||||
stopViewer = () => viewer.stop();
|
||||
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,
|
||||
@@ -56,10 +65,8 @@ export function RerunViewport({
|
||||
},
|
||||
null,
|
||||
);
|
||||
started = true;
|
||||
|
||||
if (disposed) {
|
||||
viewer.stop();
|
||||
stopViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,9 +93,9 @@ export function RerunViewport({
|
||||
setStatus("ready");
|
||||
onStatusChange?.("ready");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : "Не удалось запустить визуальный движок.";
|
||||
const message = "Не удалось запустить встроенный визуализатор.";
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
});
|
||||
@@ -96,7 +103,7 @@ export function RerunViewport({
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
if (started) stopViewer?.();
|
||||
stopViewer?.();
|
||||
onSelectionChange?.(null);
|
||||
};
|
||||
}, [onSelectionChange, onStatusChange, sourceUrl]);
|
||||
|
||||
@@ -13,8 +13,18 @@ const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
|
||||
return runtimeMessageReplacements.reduce(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -67,11 +67,10 @@ export function pipelineLatency(metrics: K1Metrics | undefined): number | null {
|
||||
|
||||
const segments = [
|
||||
finiteMetric(metrics.mqtt_to_decode_ms),
|
||||
finiteMetric(metrics.decode_ms),
|
||||
finiteMetric(metrics.publish_ms),
|
||||
].filter((value): value is number => value !== null);
|
||||
|
||||
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | null, digits = 1): string {
|
||||
|
||||
@@ -393,7 +393,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
title: "Временные представления",
|
||||
description: "Встроенные классы для чисел, состояний и таблиц.",
|
||||
capabilities: [
|
||||
active("Метрики потока в реальном времени", "Задержка, частота кадров, число точек и пропуски предпросмотра."),
|
||||
active("Метрики потока в реальном времени", "Время до Rerun SDK, частота кадров, число точек и пропуски предпросмотра."),
|
||||
contract("Временные ряды (TimeSeriesView)", "Скалярные серии, несколько линий и диапазоны."),
|
||||
contract("Шкала состояний (StateTimelineView)", "Переходы режимов, ошибки и статусы подсистем."),
|
||||
contract("Таблица данных (DataframeView)", "Табличная диагностика и атрибуты сущностей."),
|
||||
|
||||
@@ -17,6 +17,19 @@ 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,
|
||||
@@ -32,16 +45,100 @@ code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.control-station {
|
||||
.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(var(--nodedc-accent-rgb) / 0.11);
|
||||
--station-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
|
||||
--station-warning-soft: rgb(var(--nodedc-warning-rgb) / 0.1);
|
||||
--station-danger-soft: rgb(var(--nodedc-danger-rgb) / 0.1);
|
||||
--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 {
|
||||
@@ -146,9 +243,7 @@ code {
|
||||
}
|
||||
|
||||
.metric-card[data-featured="true"] {
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, rgb(var(--nodedc-accent-rgb) / 0.16), transparent 44%),
|
||||
var(--station-panel);
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.metric-card__reading {
|
||||
@@ -194,15 +289,15 @@ code {
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="accent"] {
|
||||
background: var(--station-accent-soft);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="warning"] {
|
||||
background: var(--station-warning-soft);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="success"] {
|
||||
background: var(--station-success-soft);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note > svg {
|
||||
|
||||
@@ -25,14 +25,18 @@
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--station-danger-soft);
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.error-banner > svg {
|
||||
.error-banner__dot {
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
align-self: start;
|
||||
margin-top: 0.12rem;
|
||||
margin-top: 0.28rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.error-banner strong,
|
||||
@@ -159,8 +163,8 @@
|
||||
}
|
||||
|
||||
.device-row[data-selected="true"] {
|
||||
background: var(--station-accent-soft);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
|
||||
background: rgb(255 255 255 / 0.065);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.device-row__identity,
|
||||
@@ -191,10 +195,7 @@
|
||||
|
||||
.device-row__name small {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--station-accent-soft);
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
padding: 0.18rem 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.47rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -223,12 +224,10 @@
|
||||
flex: 0 0 0.58rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
box-shadow: 0 0 0 0.24rem rgb(255 255 255 / 0.035);
|
||||
}
|
||||
|
||||
.device-row[data-compatible="true"] .device-row__signal {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
box-shadow: 0 0 0 0.24rem var(--station-success-soft);
|
||||
}
|
||||
|
||||
.device-row__action > span {
|
||||
@@ -388,10 +387,7 @@
|
||||
gap: 0.2rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 0.85rem;
|
||||
background:
|
||||
linear-gradient(to top, rgb(255 255 255 / 0.035) 1px, transparent 1px),
|
||||
rgb(255 255 255 / 0.018);
|
||||
background-size: 100% 25%;
|
||||
background: rgb(255 255 255 / 0.018);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
@@ -399,7 +395,7 @@
|
||||
min-width: 0.16rem;
|
||||
flex: 1 1 0;
|
||||
border-radius: 999px 999px 0.12rem 0.12rem;
|
||||
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb)));
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.latency-trace p {
|
||||
|
||||
@@ -120,15 +120,6 @@
|
||||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
|
||||
.landing-stage__field {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.landing-stage__orbit,
|
||||
.landing-stage__sweep {
|
||||
left: 74%;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
top: auto;
|
||||
right: 1rem;
|
||||
@@ -235,11 +226,12 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-banner > svg {
|
||||
display: none;
|
||||
.error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -252,7 +244,7 @@
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.feature-row > .nodedc-status-badge {
|
||||
.feature-row > .nodedc-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
.api-dot[data-status="online"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
|
||||
}
|
||||
|
||||
.api-dot[data-status="checking"],
|
||||
@@ -31,123 +30,12 @@
|
||||
}
|
||||
|
||||
.landing-stage {
|
||||
--landing-accent: var(--nodedc-accent-rgb);
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background:
|
||||
linear-gradient(115deg, rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5)),
|
||||
var(--station-stage);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
.landing-stage[data-root="center"] { --landing-accent: 84 177 255; }
|
||||
.landing-stage[data-root="fleet"] { --landing-accent: 81 215 179; }
|
||||
.landing-stage[data-root="observation"] { --landing-accent: 255 79 163; }
|
||||
.landing-stage[data-root="missions"] { --landing-accent: 151 104 255; }
|
||||
.landing-stage[data-root="data"] { --landing-accent: 93 192 255; }
|
||||
.landing-stage[data-root="system"] { --landing-accent: 242 175 76; }
|
||||
|
||||
.landing-stage__field,
|
||||
.landing-stage__shade,
|
||||
.landing-stage__grid,
|
||||
.landing-stage__orbit,
|
||||
.landing-stage__sweep {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.landing-stage__field {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 68% 42%, rgb(var(--landing-accent) / 0.1), transparent 24%),
|
||||
radial-gradient(circle at 76% 48%, rgb(255 255 255 / 0.035), transparent 42%);
|
||||
}
|
||||
|
||||
.landing-stage__grid {
|
||||
inset: -40% -25% -60% 25%;
|
||||
opacity: 0.42;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.055) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.055) 1px, transparent 1px);
|
||||
background-position: center;
|
||||
background-size: 4.2rem 4.2rem;
|
||||
mask-image: radial-gradient(circle at center, #000 0%, transparent 68%);
|
||||
transform: perspective(42rem) rotateX(62deg) rotateZ(-8deg) scale(1.3) translateY(15%);
|
||||
}
|
||||
|
||||
.landing-stage__orbit {
|
||||
top: 50%;
|
||||
left: 69%;
|
||||
border: 1px solid rgb(var(--landing-accent) / 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.landing-stage__orbit--one {
|
||||
width: min(22vw, 24rem);
|
||||
height: min(22vw, 24rem);
|
||||
animation: station-orbit 18s linear infinite;
|
||||
}
|
||||
|
||||
.landing-stage__orbit--two {
|
||||
width: min(34vw, 38rem);
|
||||
height: min(34vw, 38rem);
|
||||
border-color: rgb(var(--landing-accent) / 0.12);
|
||||
animation: station-orbit-reverse 26s linear infinite;
|
||||
}
|
||||
|
||||
.landing-stage__orbit--three {
|
||||
width: min(49vw, 56rem);
|
||||
height: min(49vw, 56rem);
|
||||
border-color: rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
.landing-stage__orbit::before,
|
||||
.landing-stage__orbit::after {
|
||||
position: absolute;
|
||||
width: 0.36rem;
|
||||
height: 0.36rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--landing-accent));
|
||||
box-shadow: 0 0 1.1rem rgb(var(--landing-accent) / 0.6);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.landing-stage__orbit::before { top: -0.18rem; left: 50%; }
|
||||
.landing-stage__orbit::after { right: 11%; bottom: 14%; opacity: 0.55; }
|
||||
|
||||
.landing-stage__sweep {
|
||||
left: 69%;
|
||||
width: min(32vw, 34rem);
|
||||
height: min(32vw, 34rem);
|
||||
margin: auto;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 220deg, transparent 0deg 318deg, rgb(var(--landing-accent) / 0.14) 344deg, transparent 360deg);
|
||||
transform: translateX(-50%);
|
||||
animation: station-sweep 7s linear infinite;
|
||||
}
|
||||
|
||||
.landing-stage__node {
|
||||
position: absolute;
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--landing-accent));
|
||||
box-shadow: 0 0 1.3rem rgb(var(--landing-accent) / 0.85);
|
||||
}
|
||||
|
||||
.landing-stage__node--one { top: 29%; left: 62%; }
|
||||
.landing-stage__node--two { top: 42%; left: 78%; animation: station-pulse 3.2s ease-in-out infinite; }
|
||||
.landing-stage__node--three { top: 61%; left: 69%; animation: station-pulse 4.1s ease-in-out infinite 0.8s; }
|
||||
.landing-stage__node--four { top: 52%; left: 87%; animation: station-pulse 3.6s ease-in-out infinite 1.4s; }
|
||||
|
||||
.landing-stage__shade {
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 5 6 / 0.96) 0%, rgb(5 5 6 / 0.76) 34%, transparent 67%),
|
||||
linear-gradient(0deg, rgb(5 5 6 / 0.86) 0%, transparent 28%);
|
||||
background: var(--station-stage);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
@@ -226,28 +114,3 @@
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
@keyframes station-orbit {
|
||||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes station-orbit-reverse {
|
||||
to { transform: translate(-50%, -50%) rotate(-360deg); }
|
||||
}
|
||||
|
||||
@keyframes station-sweep {
|
||||
to { transform: translateX(-50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes station-pulse {
|
||||
0%, 100% { opacity: 0.34; transform: scale(0.75); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-stage__orbit,
|
||||
.landing-stage__sweep,
|
||||
.landing-stage__node {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@
|
||||
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%;
|
||||
@@ -82,7 +87,7 @@
|
||||
}
|
||||
|
||||
.rerun-viewport__notice--error {
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.12);
|
||||
background: rgb(10 11 14 / 0.9);
|
||||
}
|
||||
|
||||
.busy-indicator {
|
||||
@@ -90,7 +95,7 @@
|
||||
height: 1rem;
|
||||
flex: 0 0 1rem;
|
||||
border: 2px solid rgb(255 255 255 / 0.12);
|
||||
border-top-color: rgb(var(--nodedc-accent-rgb));
|
||||
border-top-color: var(--nodedc-text-primary);
|
||||
border-radius: 50%;
|
||||
animation: viewer-spin 900ms linear infinite;
|
||||
}
|
||||
@@ -103,26 +108,11 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 68% 41%, rgb(var(--nodedc-accent-rgb) / 0.075), transparent 28%),
|
||||
linear-gradient(125deg, #08090c, #040508);
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__grid {
|
||||
position: absolute;
|
||||
inset: -25% -15% -85%;
|
||||
opacity: 0;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.07) 1px, transparent 1px);
|
||||
background-position: center;
|
||||
background-size: 3.7rem 3.7rem;
|
||||
mask-image: radial-gradient(circle at center, #000 0%, transparent 70%);
|
||||
transform: perspective(40rem) rotateX(66deg) rotateZ(-5deg) scale(1.45) translateY(8%);
|
||||
}
|
||||
|
||||
.empty-spatial-stage[data-grid="true"] .empty-spatial-stage__grid {
|
||||
opacity: 0.52;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spatial-axis {
|
||||
@@ -150,9 +140,9 @@
|
||||
.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: #ff6a7a; }
|
||||
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: #75d98a; }
|
||||
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: #67a9ff; }
|
||||
.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;
|
||||
@@ -256,7 +246,7 @@
|
||||
transform: translate(-50%, 5.2rem);
|
||||
}
|
||||
|
||||
.scene-adapter-note > svg { flex: 0 0 auto; color: rgb(var(--nodedc-warning-rgb)); }
|
||||
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
|
||||
|
||||
.scene-selection {
|
||||
right: 0.85rem;
|
||||
@@ -296,24 +286,39 @@
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
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: rgb(255 255 255 / 0.055);
|
||||
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: var(--station-success-soft);
|
||||
color: rgb(var(--nodedc-success-rgb));
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.spatial-contract-strip {
|
||||
@@ -339,7 +344,7 @@
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.spatial-contract-strip i[data-state="ready"] { background: rgb(var(--nodedc-accent-rgb)); }
|
||||
.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) {
|
||||
|
||||
@@ -64,17 +64,14 @@
|
||||
|
||||
.feature-row[data-status="active"] .feature-row__mark {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
box-shadow: 0 0 0 0.25rem var(--station-success-soft);
|
||||
}
|
||||
|
||||
.feature-row[data-status="ready"] .feature-row__mark {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
box-shadow: 0 0 0 0.25rem var(--station-accent-soft);
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.feature-row[data-status="contract"] .feature-row__mark {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
box-shadow: 0 0 0 0.25rem var(--station-warning-soft);
|
||||
}
|
||||
|
||||
.feature-row strong,
|
||||
@@ -149,6 +146,7 @@
|
||||
}
|
||||
|
||||
.pipeline-strip > div {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 6rem;
|
||||
@@ -159,12 +157,23 @@
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.pipeline-strip > div[data-state="ready"] {
|
||||
background: var(--station-success-soft);
|
||||
.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="contract"] {
|
||||
background: var(--station-warning-soft);
|
||||
.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 {
|
||||
@@ -271,12 +280,7 @@
|
||||
.camera-slot__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.22;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
|
||||
background-size: 3rem 3rem;
|
||||
mask-image: radial-gradient(circle at center, #000, transparent 77%);
|
||||
background: rgb(255 255 255 / 0.012);
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
@@ -342,9 +346,7 @@
|
||||
min-height: 30rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at 72% 38%, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 28%),
|
||||
#0a0b0d;
|
||||
background: #0a0b0d;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
}
|
||||
|
||||
@@ -355,11 +357,7 @@
|
||||
}
|
||||
|
||||
.map-canvas__grid {
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.045) 1px, transparent 1px);
|
||||
background-size: 2.5rem 2.5rem;
|
||||
background: rgb(255 255 255 / 0.012);
|
||||
}
|
||||
|
||||
.map-canvas__contours span {
|
||||
@@ -383,15 +381,14 @@
|
||||
|
||||
.map-canvas svg path {
|
||||
fill: none;
|
||||
stroke: rgb(var(--nodedc-accent-rgb));
|
||||
stroke: var(--nodedc-text-secondary);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 8px rgb(var(--nodedc-accent-rgb) / 0.34));
|
||||
}
|
||||
|
||||
.map-canvas svg circle {
|
||||
fill: #0a0b0d;
|
||||
stroke: rgb(var(--nodedc-accent-rgb));
|
||||
stroke: var(--nodedc-text-secondary);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
@@ -448,7 +445,7 @@
|
||||
}
|
||||
|
||||
.layer-summary-list > div > span[data-tone="cyan"] { background: #35d7c1; }
|
||||
.layer-summary-list > div > span[data-tone="violet"] { background: #9a72ff; }
|
||||
.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; }
|
||||
|
||||
@@ -529,8 +526,7 @@
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
box-shadow: 0 0 0 0.26rem var(--station-accent-soft);
|
||||
background: var(--nodedc-text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
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";
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
@@ -26,6 +27,7 @@ function messageFor(error: unknown): string {
|
||||
}
|
||||
|
||||
function measuredLatency(state: ConsoleState | null): number | null {
|
||||
if (state?.source_mode !== "live") return null;
|
||||
const metrics = state?.metrics;
|
||||
if (!metrics) return null;
|
||||
|
||||
@@ -34,11 +36,10 @@ function measuredLatency(state: ConsoleState | null): number | null {
|
||||
|
||||
const segments = [
|
||||
metrics.mqtt_to_decode_ms,
|
||||
metrics.decode_ms,
|
||||
metrics.publish_ms,
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
|
||||
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
||||
}
|
||||
|
||||
export function useK1Console() {
|
||||
@@ -130,6 +131,11 @@ export function useK1Console() {
|
||||
[run],
|
||||
);
|
||||
|
||||
const updateViewerSettings = useCallback(
|
||||
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh(true);
|
||||
@@ -171,7 +177,10 @@ export function useK1Console() {
|
||||
|
||||
useEffect(() => {
|
||||
const latency = measuredLatency(state);
|
||||
if (latency === null) return;
|
||||
if (latency === null) {
|
||||
setLatencyHistory((values) => (values.length ? [] : values));
|
||||
return;
|
||||
}
|
||||
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
||||
}, [state]);
|
||||
|
||||
@@ -189,5 +198,6 @@ export function useK1Console() {
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
updateViewerSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ function DeviceRow({
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "accent" : "secondary"}
|
||||
variant={selected ? "primary" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
@@ -116,7 +116,7 @@ function LatencyTrace({ values }: { values: number[] }) {
|
||||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<div className="latency-trace" aria-label="Последние измерения задержки потока">
|
||||
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<span
|
||||
@@ -132,7 +132,13 @@ function LatencyTrace({ values }: { values: number[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
export function DeviceWorkspace({
|
||||
console,
|
||||
onOpenSpatialScene,
|
||||
}: {
|
||||
console: ConsoleController;
|
||||
onOpenSpatialScene: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
backendStatus,
|
||||
@@ -173,7 +179,8 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
const metrics = state?.metrics;
|
||||
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);
|
||||
@@ -200,25 +207,27 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
if (succeeded) setPassword("");
|
||||
};
|
||||
|
||||
const submitLive = () => {
|
||||
const submitLive = async () => {
|
||||
const host = liveHost.trim();
|
||||
void startLive(host ? { host } : {});
|
||||
const started = await startLive(host ? { host } : {});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
const submitReplay = () => {
|
||||
const submitReplay = async () => {
|
||||
const speed = Number(replaySpeed);
|
||||
void startReplay({
|
||||
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">
|
||||
<Icon name="alert" size={18} />
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
@@ -252,10 +261,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ЗАДЕРЖКА КОНТУРА"
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="Приём MQTT → публикация предпросмотра"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА КАДРОВ"
|
||||
@@ -387,7 +396,7 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
variant="accent"
|
||||
variant="primary"
|
||||
icon={<Icon name="network" />}
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
@@ -434,10 +443,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !liveTargetReady}
|
||||
onClick={submitLive}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||
</Button>
|
||||
@@ -473,10 +482,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
onChange={setReplayLoop}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
onClick={submitReplay}
|
||||
onClick={() => void submitReplay()}
|
||||
>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
</Button>
|
||||
@@ -521,7 +530,7 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||
<h2>Задержка потока</h2>
|
||||
<h2>Время до публикации</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>мс</span>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
@@ -21,15 +20,9 @@ import {
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneProjection, SceneSettings } from "../sceneSettings";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
const projectionItems = [
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "2d", label: "2D" },
|
||||
{ value: "map", label: "Карта" },
|
||||
] satisfies Array<{ value: SceneProjection; label: string }>;
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
@@ -93,7 +86,6 @@ export interface WorkspaceRendererProps {
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
sceneSettings: SceneSettings;
|
||||
onSceneSettingsChange: (settings: SceneSettings) => void;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
||||
@@ -103,12 +95,13 @@ function OverviewWorkspace({
|
||||
backendStatus,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const metrics = state?.metrics;
|
||||
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 streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const rerunReady = Boolean(state?.rerun_grpc_url);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace overview-workspace">
|
||||
@@ -119,10 +112,10 @@ function OverviewWorkspace({
|
||||
<section className="metrics-grid" aria-label="Оперативные показатели">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ЗАДЕРЖКА"
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="Локальный путь приёма и публикации"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА"
|
||||
@@ -166,10 +159,10 @@ function OverviewWorkspace({
|
||||
<small>{sourceModeLabel(state?.source_mode)}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state="contract">
|
||||
<div data-state={rerunReady ? "ready" : "idle"}>
|
||||
<span>03</span>
|
||||
<strong>Rerun</strong>
|
||||
<small>ожидает источник</small>
|
||||
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state="ready">
|
||||
@@ -227,7 +220,6 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
<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="y">Y</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
@@ -246,13 +238,13 @@ function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
sceneSettings,
|
||||
onSceneSettingsChange,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const metrics = state?.metrics;
|
||||
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);
|
||||
@@ -276,12 +268,7 @@ function SpatialWorkspace({
|
||||
<div className="spatial-workspace">
|
||||
<div className="spatial-toolbar">
|
||||
<div className="spatial-toolbar__mode">
|
||||
<SegmentedControl
|
||||
label="Проекция сцены"
|
||||
value={sceneSettings.projection}
|
||||
items={projectionItems}
|
||||
onChange={(projection) => onSceneSettingsChange({ ...sceneSettings, projection })}
|
||||
/>
|
||||
<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}>
|
||||
@@ -323,7 +310,7 @@ function SpatialWorkspace({
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Задержка</span>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,7 +320,7 @@ function SpatialWorkspace({
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
но Rerun-адаптер пока не назначен.
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -366,7 +353,7 @@ function SpatialWorkspace({
|
||||
<div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="contract" />Преобразования</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>
|
||||
@@ -532,7 +519,7 @@ function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
<span><i />Связь</span>
|
||||
<span><i />Безопасность</span>
|
||||
</div>
|
||||
<Button variant="accent" width="full" disabled>Сохранить миссию</Button>
|
||||
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
|
||||
Reference in New Issue
Block a user