feat: activate self-hosted lidar spatial scene

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 10:36:51 +03:00
parent 6402601d6d
commit 904c6ea13a
27 changed files with 2009 additions and 646 deletions
+76 -44
View File
@@ -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.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
@@ -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
View File
@@ -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"
+17
View File
@@ -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";
+1 -14
View File
@@ -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]);
+11 -1
View File
@@ -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;
}
+1 -2
View File
@@ -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 {
+1 -1
View File
@@ -393,7 +393,7 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Временные представления",
description: "Встроенные классы для чисел, состояний и таблиц.",
capabilities: [
active("Метрики потока в реальном времени", "Задержка, частота кадров, число точек и пропуски предпросмотра."),
active("Метрики потока в реальном времени", "Время до Rerun SDK, частота кадров, число точек и пропуски предпросмотра."),
contract("Временные ряды (TimeSeriesView)", "Скалярные серии, несколько линий и диапазоны."),
contract("Шкала состояний (StateTimelineView)", "Переходы режимов, ошибки и статусы подсистем."),
contract("Таблица данных (DataframeView)", "Табличная диагностика и атрибуты сущностей."),
+106 -11
View File
@@ -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 {
+13 -17
View File
@@ -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 {
+4 -12
View File
@@ -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);
}
}
+1 -138
View File
@@ -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;
}
}
+33 -28
View File
@@ -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) {
+24 -28
View File
@@ -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%);
}
+14 -4
View File
@@ -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>
+15 -28
View File
@@ -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} />