Share the K1 live scene template and preserve idle media channels
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
|
||||||
|
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AdminNavigationPanel,
|
AdminNavigationPanel,
|
||||||
@@ -5,15 +7,11 @@ import {
|
|||||||
ApplicationPanel,
|
ApplicationPanel,
|
||||||
ApplicationShell,
|
ApplicationShell,
|
||||||
Button,
|
Button,
|
||||||
Checker,
|
|
||||||
ColorField,
|
|
||||||
ControlRow,
|
ControlRow,
|
||||||
HeaderNavigation,
|
HeaderNavigation,
|
||||||
HeaderProfile,
|
HeaderProfile,
|
||||||
Icon,
|
Icon,
|
||||||
Inspector,
|
Inspector,
|
||||||
RangeControl,
|
|
||||||
Select,
|
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TextField,
|
TextField,
|
||||||
UserProfileMenu,
|
UserProfileMenu,
|
||||||
@@ -66,8 +64,6 @@ import {
|
|||||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||||
import {
|
import {
|
||||||
defaultSceneSettings,
|
defaultSceneSettings,
|
||||||
type PointColorMode,
|
|
||||||
type PointPalette,
|
|
||||||
type SceneSettings,
|
type SceneSettings,
|
||||||
} from "./sceneSettings";
|
} from "./sceneSettings";
|
||||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||||
@@ -77,22 +73,6 @@ import "./styles/scene-windows.css";
|
|||||||
type SceneToolWindowId = "sources" | "display" | "layers";
|
type SceneToolWindowId = "sources" | "display" | "layers";
|
||||||
const viewerSettingsQuietPeriodMs = 750;
|
const viewerSettingsQuietPeriodMs = 750;
|
||||||
|
|
||||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
|
||||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
|
||||||
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
|
|
||||||
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
|
|
||||||
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
|
|
||||||
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
|
|
||||||
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
|
|
||||||
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
|
|
||||||
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
|
|
||||||
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
|
|
||||||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface LivePerceptionLayers {
|
interface LivePerceptionLayers {
|
||||||
detections2d: boolean;
|
detections2d: boolean;
|
||||||
segmentation: boolean;
|
segmentation: boolean;
|
||||||
@@ -1029,132 +1009,8 @@ export default function App() {
|
|||||||
onPointerDown={() => activateSceneWindow("display")}
|
onPointerDown={() => activateSceneWindow("display")}
|
||||||
onClose={() => closeSceneWindow("display")}
|
onClose={() => closeSceneWindow("display")}
|
||||||
>
|
>
|
||||||
<Inspector
|
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||||
defaultOpen={["points"]}
|
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||||
singleOpen
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
id: "points",
|
|
||||||
label: "Облако точек",
|
|
||||||
description: "Размер и способ окрашивания",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div
|
|
||||||
className="scene-settings-commit-field"
|
|
||||||
onPointerUp={flushDisplaySettings}
|
|
||||||
onKeyUp={flushDisplaySettings}
|
|
||||||
onBlur={flushDisplaySettings}
|
|
||||||
>
|
|
||||||
<RangeControl
|
|
||||||
label="Размер точки"
|
|
||||||
value={displayDraft.pointSize}
|
|
||||||
min={0.5}
|
|
||||||
max={12}
|
|
||||||
step={0.5}
|
|
||||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
|
||||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
|
||||||
<ControlRow label="Атрибут цвета">
|
|
||||||
<Select
|
|
||||||
variant="split"
|
|
||||||
label="Атрибут цвета"
|
|
||||||
value={displayDraft.colorMode}
|
|
||||||
options={colorModeOptions}
|
|
||||||
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
</div>
|
|
||||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
|
||||||
<ControlRow label="Палитра">
|
|
||||||
<Select
|
|
||||||
variant="split"
|
|
||||||
label="Палитра"
|
|
||||||
value={displayDraft.palette}
|
|
||||||
options={paletteOptions}
|
|
||||||
onChange={(palette) => stageDisplayPatch({ palette })}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
</div>
|
|
||||||
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
|
|
||||||
<div
|
|
||||||
className="scene-settings-commit-field"
|
|
||||||
onPointerUp={flushDisplaySettings}
|
|
||||||
onKeyUp={flushDisplaySettings}
|
|
||||||
onBlur={flushDisplaySettings}
|
|
||||||
>
|
|
||||||
<ControlRow label="Цвет точек">
|
|
||||||
<ColorField
|
|
||||||
label="Цвет точек"
|
|
||||||
value={displayDraft.customColor}
|
|
||||||
onChange={(customColor) => stageDisplayPatch({ customColor })}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{replayPresented ? (
|
|
||||||
<p className="scene-window-note">
|
|
||||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
|
||||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "history",
|
|
||||||
label: "Накопление и время",
|
|
||||||
description: "История облака и траектория",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div
|
|
||||||
className="scene-settings-commit-field"
|
|
||||||
onPointerUp={flushDisplaySettings}
|
|
||||||
onKeyUp={flushDisplaySettings}
|
|
||||||
onBlur={flushDisplaySettings}
|
|
||||||
>
|
|
||||||
<RangeControl
|
|
||||||
label="Окно накопления"
|
|
||||||
value={displayDraft.accumulationSeconds}
|
|
||||||
min={0}
|
|
||||||
max={120}
|
|
||||||
step={1}
|
|
||||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
|
||||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
|
||||||
<Checker
|
|
||||||
checked={displayDraft.showTrajectory}
|
|
||||||
label="Показывать траекторию"
|
|
||||||
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "scene",
|
|
||||||
label: "Окружение сцены",
|
|
||||||
description: "Сетка, подписи и камеры",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
|
||||||
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Window>
|
</Window>
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
@@ -1180,64 +1036,7 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||||
</div>
|
</div>
|
||||||
<Inspector
|
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
|
||||||
defaultOpen={["geometry"]}
|
|
||||||
singleOpen
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
id: "geometry",
|
|
||||||
label: "Геометрия",
|
|
||||||
description: "Облако точек и путь",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
|
|
||||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
|
|
||||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "frames",
|
|
||||||
label: "Координаты и камеры",
|
|
||||||
description: "Преобразования и области обзора",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "perception",
|
|
||||||
label: "Объекты и маски",
|
|
||||||
description: "Ожидают внешние обработчики",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Window>
|
</Window>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
import type {RefObject} from "react";
|
||||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
import {FloatingMediaWindow} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||||
|
export {initialObservationWindowRect,shouldCaptureWorkspacePointer} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||||
|
|
||||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||||
@@ -10,60 +11,6 @@ import type {
|
|||||||
RecordedCameraAdmissionState,
|
RecordedCameraAdmissionState,
|
||||||
} from "../core/observation/recordedSessionAdmission";
|
} from "../core/observation/recordedSessionAdmission";
|
||||||
|
|
||||||
const WINDOW_WIDTH = 336;
|
|
||||||
const WINDOW_HEIGHT = 210;
|
|
||||||
const WINDOW_GAP = 16;
|
|
||||||
const WINDOW_INSET = 18;
|
|
||||||
const TIMELINE_CLEARANCE = 64;
|
|
||||||
|
|
||||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
|
||||||
|
|
||||||
export function shouldCaptureWorkspacePointer(
|
|
||||||
button: number,
|
|
||||||
target: ClosestTarget | null,
|
|
||||||
): boolean {
|
|
||||||
if (button !== 0 || !target) return false;
|
|
||||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
|
||||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
|
||||||
return !target.closest("button, input, select, textarea, a");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initialObservationWindowRect(
|
|
||||||
index: number,
|
|
||||||
count = 1,
|
|
||||||
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
|
||||||
): ObservationWindowRect {
|
|
||||||
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
|
||||||
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
|
||||||
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
|
||||||
const maxColumns = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
|
||||||
);
|
|
||||||
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
|
||||||
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
|
||||||
const rowHeight = Math.max(
|
|
||||||
1,
|
|
||||||
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
|
||||||
);
|
|
||||||
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
|
||||||
const row = Math.floor(index / columns);
|
|
||||||
const column = index % columns;
|
|
||||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
|
||||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
|
||||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
|
||||||
y: Math.max(
|
|
||||||
0,
|
|
||||||
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
|
||||||
),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingObservationWindow({
|
export function FloatingObservationWindow({
|
||||||
source,
|
source,
|
||||||
index,
|
index,
|
||||||
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
|
|||||||
state: RecordedCameraAdmissionState,
|
state: RecordedCameraAdmissionState,
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
return <FloatingMediaWindow title={source.label} subtitle={source.description}
|
||||||
|
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
|
||||||
useLayoutEffect(() => {
|
active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
|
||||||
const element = boundsRef.current;
|
onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
|
||||||
if (!element) return;
|
status={<span className="floating-observation-window__status"><i data-availability={source.availability} aria-hidden="true"/>{observationSourceStatusLabel(source)}</span>}
|
||||||
|
footer={<span className="floating-observation-window__footer"><span>{source.endpointLabel || source.transport}</span><span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span></span>}>
|
||||||
const measure = () => {
|
|
||||||
const next = { width: element.clientWidth, height: element.clientHeight };
|
|
||||||
setBounds((current) => (
|
|
||||||
current?.width === next.width && current.height === next.height ? current : next
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
measure();
|
|
||||||
const observer = new ResizeObserver(measure);
|
|
||||||
observer.observe(element);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [boundsRef]);
|
|
||||||
|
|
||||||
// Camera media admission must not wait for the viewport observer. A source
|
|
||||||
// can arrive in the same commit as a workspace transition; mounting it with
|
|
||||||
// the canonical fallback geometry keeps the browser transport alive until
|
|
||||||
// the real bounds are measured immediately afterward.
|
|
||||||
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
|
||||||
const windowRect = rect ?? initialRect;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkspaceWindow
|
|
||||||
boundsRef={boundsRef}
|
|
||||||
rect={windowRect}
|
|
||||||
onRectChange={onRectChange}
|
|
||||||
maximized={maximized}
|
|
||||||
onMaximizedChange={onMaximizedChange}
|
|
||||||
onActivate={onActivate}
|
|
||||||
onClose={onClose}
|
|
||||||
title={source.label}
|
|
||||||
subtitle={source.description}
|
|
||||||
status={(
|
|
||||||
<span className="floating-observation-window__status">
|
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
|
||||||
{observationSourceStatusLabel(source)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
footer={(
|
|
||||||
<span className="floating-observation-window__footer">
|
|
||||||
<span>{source.endpointLabel || source.transport}</span>
|
|
||||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
minWidth={Math.min(280, windowRect.width)}
|
|
||||||
minHeight={Math.min(190, windowRect.height)}
|
|
||||||
resizable={source.capabilities.resizable}
|
|
||||||
active={active}
|
|
||||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
|
||||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
|
||||||
try {
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
} catch {
|
|
||||||
// Pointer capture can fail if the browser ended the pointer between
|
|
||||||
// dispatch and capture. The donor's window listeners remain fallback.
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
closeLabel={`Закрыть ${source.label}`}
|
|
||||||
maximizeLabel={`Развернуть ${source.label}`}
|
|
||||||
restoreLabel={`Восстановить ${source.label}`}
|
|
||||||
moveLabel={`Переместить ${source.label}`}
|
|
||||||
resizeLabel={`Изменить размер ${source.label}`}
|
|
||||||
>
|
|
||||||
<ObservationMedia
|
<ObservationMedia
|
||||||
source={source}
|
source={source}
|
||||||
playback={playback}
|
playback={playback}
|
||||||
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
|
|||||||
recordedAdmissionKey={recordedAdmissionKey}
|
recordedAdmissionKey={recordedAdmissionKey}
|
||||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||||
/>
|
/>
|
||||||
</WorkspaceWindow>
|
</FloatingMediaWindow>;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||||
|
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||||
|
import { Icon } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ObservationSourceAvailability,
|
|
||||||
ObservationSourceDescriptor,
|
ObservationSourceDescriptor,
|
||||||
ObservationSourceModality,
|
|
||||||
} from "../core/runtime/contracts";
|
} from "../core/runtime/contracts";
|
||||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||||
import {
|
import {
|
||||||
@@ -16,28 +16,6 @@ import type {
|
|||||||
} from "../core/observation/recordedSessionAdmission";
|
} from "../core/observation/recordedSessionAdmission";
|
||||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||||
|
|
||||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
|
||||||
"point-cloud": "globe",
|
|
||||||
video: "video",
|
|
||||||
image: "image",
|
|
||||||
depth: "image",
|
|
||||||
};
|
|
||||||
|
|
||||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
|
||||||
unverified: "Не подтверждён",
|
|
||||||
declared: "Канал объявлен",
|
|
||||||
available: "Доступен",
|
|
||||||
connecting: "Подключение",
|
|
||||||
streaming: "Эфир",
|
|
||||||
degraded: "Нестабильно",
|
|
||||||
unavailable: "Недоступен",
|
|
||||||
error: "Ошибка",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
|
||||||
return availabilityCopy[source.availability];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ObservationMedia({
|
export function ObservationMedia({
|
||||||
source,
|
source,
|
||||||
playback,
|
playback,
|
||||||
@@ -159,105 +137,3 @@ export function ObservationMedia({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObservationSourcePicker({
|
|
||||||
sources,
|
|
||||||
visibleSourceIds,
|
|
||||||
pendingSourceIds,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
sources: readonly ObservationSourceDescriptor[];
|
|
||||||
visibleSourceIds: ReadonlySet<string>;
|
|
||||||
pendingSourceIds?: ReadonlySet<string>;
|
|
||||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
|
||||||
}) {
|
|
||||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
className="scene-source-picker"
|
|
||||||
placement="bottom-start"
|
|
||||||
width={340}
|
|
||||||
offset={10}
|
|
||||||
surfaceRole="dialog"
|
|
||||||
surfaceClassName="observation-source-menu"
|
|
||||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
|
||||||
<button
|
|
||||||
ref={(node) => {
|
|
||||||
setAnchorRef(node);
|
|
||||||
setTriggerRef(node);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
className="scene-source-picker__trigger"
|
|
||||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
|
||||||
aria-label="Источники данных сцены"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={surfaceId}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<Icon name="database" size={17} />
|
|
||||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header className="observation-source-menu__head">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
|
||||||
<strong>Источники данных</strong>
|
|
||||||
</div>
|
|
||||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
|
||||||
</header>
|
|
||||||
{sources.length ? (
|
|
||||||
<div className="observation-source-menu__list">
|
|
||||||
{sources.map((source) => {
|
|
||||||
const selected = visibleSourceIds.has(source.id);
|
|
||||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
|
||||||
const canOpen = Boolean(
|
|
||||||
selected ||
|
|
||||||
source.modality === "point-cloud" ||
|
|
||||||
source.previewUrl ||
|
|
||||||
source.delivery ||
|
|
||||||
source.activation?.controllable,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={source.id}
|
|
||||||
type="button"
|
|
||||||
className="nodedc-dropdown-option observation-source-option"
|
|
||||||
data-selected={selected ? "true" : undefined}
|
|
||||||
aria-pressed={selected}
|
|
||||||
disabled={pending || !canOpen}
|
|
||||||
onClick={() => void onToggle(source.id)}
|
|
||||||
>
|
|
||||||
<span className="nodedc-dropdown-option__icon">
|
|
||||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
|
||||||
</span>
|
|
||||||
<span className="nodedc-dropdown-option__body">
|
|
||||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
|
||||||
<span className="nodedc-dropdown-option__description">
|
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
|
||||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="nodedc-dropdown-option__check">
|
|
||||||
{selected ? <Icon name="check" size={15} /> : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="observation-source-menu__empty">
|
|
||||||
<Icon name="database" size={18} />
|
|
||||||
<strong>Каталог пока пуст</strong>
|
|
||||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<footer className="observation-source-menu__foot">
|
|
||||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
|
||||||
модель оборудования.
|
|
||||||
</footer>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,197 +1 @@
|
|||||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
|
||||||
|
|
||||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
|
||||||
|
|
||||||
export function ObservationTimeline({
|
|
||||||
active,
|
|
||||||
sourceCount,
|
|
||||||
mode = "live-only",
|
|
||||||
seekable = false,
|
|
||||||
synchronization = "host-arrival-best-effort",
|
|
||||||
rangeNs = null,
|
|
||||||
currentNs = null,
|
|
||||||
playing = false,
|
|
||||||
onSeek,
|
|
||||||
onPlayingChange,
|
|
||||||
onJumpToEnd,
|
|
||||||
showJumpToEnd = true,
|
|
||||||
playbackRate,
|
|
||||||
onPlaybackRateChange,
|
|
||||||
accumulationSeconds,
|
|
||||||
onAccumulationChange,
|
|
||||||
onAccumulationCommit,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
sourceCount: number;
|
|
||||||
mode?: ObservationTimelineMode;
|
|
||||||
seekable?: boolean;
|
|
||||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
|
||||||
rangeNs?: { min: number; max: number } | null;
|
|
||||||
currentNs?: number | null;
|
|
||||||
playing?: boolean;
|
|
||||||
onSeek?: (timeNs: number) => void;
|
|
||||||
onPlayingChange?: (playing: boolean) => void;
|
|
||||||
onJumpToEnd?: () => void;
|
|
||||||
showJumpToEnd?: boolean;
|
|
||||||
playbackRate?: number;
|
|
||||||
onPlaybackRateChange?: (rate: number) => void;
|
|
||||||
accumulationSeconds?: number;
|
|
||||||
onAccumulationChange?: (value: number) => void;
|
|
||||||
onAccumulationCommit?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
|
||||||
const buffered = Boolean(
|
|
||||||
seekable &&
|
|
||||||
(mode === "buffered" || mode === "recorded") &&
|
|
||||||
playbackRange,
|
|
||||||
);
|
|
||||||
const elapsedSeconds = playbackRange
|
|
||||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
|
||||||
: 0;
|
|
||||||
const durationSeconds = playbackRange
|
|
||||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
|
||||||
: 0;
|
|
||||||
const synchronizationLabel = {
|
|
||||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
|
||||||
"shared-clock": "Общие часы",
|
|
||||||
"frame-accurate": "Покадровая синхронизация",
|
|
||||||
}[synchronization];
|
|
||||||
const accumulationValue = accumulationSeconds === undefined
|
|
||||||
? null
|
|
||||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`observation-timeline ${className}`.trim()}
|
|
||||||
data-active={active ? "true" : undefined}
|
|
||||||
data-mode={mode}
|
|
||||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
|
||||||
>
|
|
||||||
{accumulationValue !== null ? (
|
|
||||||
<div className="observation-timeline__accumulation">
|
|
||||||
<span>Накопление</span>
|
|
||||||
<input
|
|
||||||
className="observation-timeline__track"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={120}
|
|
||||||
step={1}
|
|
||||||
value={accumulationValue}
|
|
||||||
aria-label="Окно накопления облака точек"
|
|
||||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
|
||||||
onChange={(event) =>
|
|
||||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
|
||||||
onPointerUp={onAccumulationCommit}
|
|
||||||
onTouchEnd={onAccumulationCommit}
|
|
||||||
onKeyUp={onAccumulationCommit}
|
|
||||||
onBlur={onAccumulationCommit}
|
|
||||||
/>
|
|
||||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="observation-timeline__playback">
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!buffered || !onSeek}
|
|
||||||
aria-label="Перейти к началу"
|
|
||||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
|
||||||
>
|
|
||||||
<Icon name="chevron-left" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="observation-timeline__transport"
|
|
||||||
size="compact"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!buffered || !onPlayingChange}
|
|
||||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
|
||||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
|
||||||
onClick={() => onPlayingChange?.(!playing)}
|
|
||||||
/>
|
|
||||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
|
||||||
<Select
|
|
||||||
label="Скорость воспроизведения"
|
|
||||||
value={String(playbackRate)}
|
|
||||||
options={[
|
|
||||||
{ value: "0.5", label: "0,5×" },
|
|
||||||
{ value: "1", label: "1×" },
|
|
||||||
{ value: "2", label: "2×" },
|
|
||||||
]}
|
|
||||||
variant="inline"
|
|
||||||
menuWidth="anchor"
|
|
||||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<input
|
|
||||||
className="observation-timeline__track"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={Math.max(durationSeconds, 0.001)}
|
|
||||||
step={0.01}
|
|
||||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
|
||||||
disabled={!buffered || !onSeek}
|
|
||||||
aria-label="Позиция воспроизведения"
|
|
||||||
onChange={(event) => {
|
|
||||||
if (!playbackRange) return;
|
|
||||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="observation-timeline__meta">
|
|
||||||
<code>
|
|
||||||
{buffered
|
|
||||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
|
||||||
: active ? "LIVE" : "—:—:—.———"}
|
|
||||||
</code>
|
|
||||||
<small>
|
|
||||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
{showJumpToEnd ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="observation-timeline__follow"
|
|
||||||
data-active={!buffered && active ? "true" : undefined}
|
|
||||||
disabled={buffered ? !onJumpToEnd : true}
|
|
||||||
onClick={onJumpToEnd}
|
|
||||||
>
|
|
||||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAccumulationSeconds(value: number): number {
|
|
||||||
if (!Number.isFinite(value)) return 0;
|
|
||||||
return Math.min(120, Math.max(0, Math.round(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAccumulationDuration(value: number): string {
|
|
||||||
const seconds = normalizeAccumulationSeconds(value);
|
|
||||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeTimelineRange(
|
|
||||||
range: { min: number; max: number } | null | undefined,
|
|
||||||
): { min: number; max: number } | null {
|
|
||||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
|
||||||
if (range.max <= range.min) return null;
|
|
||||||
return range;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timelineOffsetSeconds(
|
|
||||||
range: { min: number; max: number },
|
|
||||||
currentNs: number,
|
|
||||||
): number {
|
|
||||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
|
||||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTimelineDuration(seconds: number): string {
|
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
|
||||||
const wholeMinutes = Math.floor(seconds / 60);
|
|
||||||
const remaining = seconds - wholeMinutes * 60;
|
|
||||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,31 +1 @@
|
|||||||
export type SceneProjection = "3d" | "2d" | "map";
|
export * from "../../../packages/spatial-ui/src/sceneSettings";
|
||||||
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
|
||||||
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
|
||||||
|
|
||||||
export interface SceneSettings {
|
|
||||||
projection: SceneProjection;
|
|
||||||
pointSize: number;
|
|
||||||
colorMode: PointColorMode;
|
|
||||||
palette: PointPalette;
|
|
||||||
customColor: string;
|
|
||||||
accumulationSeconds: number;
|
|
||||||
showPoints: boolean;
|
|
||||||
showTrajectory: boolean;
|
|
||||||
showGrid: boolean;
|
|
||||||
showLabels: boolean;
|
|
||||||
showCameraFrustums: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultSceneSettings: SceneSettings = {
|
|
||||||
projection: "3d",
|
|
||||||
pointSize: 2.5,
|
|
||||||
colorMode: "intensity",
|
|
||||||
palette: "turbo",
|
|
||||||
customColor: "#35d7c1",
|
|
||||||
accumulationSeconds: 12,
|
|
||||||
showPoints: true,
|
|
||||||
showTrajectory: true,
|
|
||||||
showGrid: true,
|
|
||||||
showLabels: false,
|
|
||||||
showCameraFrustums: true,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,659 +1 @@
|
|||||||
.scene-source-controls {
|
@import "../../../../packages/spatial-ui/src/observation.css";
|
||||||
position: absolute;
|
|
||||||
z-index: 12;
|
|
||||||
top: 0.85rem;
|
|
||||||
left: 0.85rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-device-controls {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 30;
|
|
||||||
top: 0.85rem;
|
|
||||||
left: 50%;
|
|
||||||
max-width: calc(100% - 9rem);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-source-picker__trigger,
|
|
||||||
.scene-source-control,
|
|
||||||
.scene-focus-exit {
|
|
||||||
display: inline-grid;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 2.75rem;
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.09);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgb(12 13 16 / 0.82);
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-navigation-hint {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 11;
|
|
||||||
right: 0.85rem;
|
|
||||||
bottom: 4.9rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.07);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(9 10 13 / 0.7);
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
padding: 0.42rem 0.65rem;
|
|
||||||
font-size: 0.52rem;
|
|
||||||
font-weight: 680;
|
|
||||||
pointer-events: none;
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-source-picker__trigger {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-source-picker__trigger:hover,
|
|
||||||
.scene-source-control:hover,
|
|
||||||
.scene-focus-exit:hover,
|
|
||||||
.scene-source-picker__trigger[data-active="true"] {
|
|
||||||
border-color: rgb(255 255 255 / 0.18);
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-source-picker__trigger > span {
|
|
||||||
position: absolute;
|
|
||||||
top: -0.2rem;
|
|
||||||
right: -0.2rem;
|
|
||||||
display: grid;
|
|
||||||
min-width: 1.05rem;
|
|
||||||
height: 1.05rem;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--nodedc-text-primary);
|
|
||||||
color: #090a0c;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
font-size: 0.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-focus-exit {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
top: 0.85rem;
|
|
||||||
right: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-status--top-left {
|
|
||||||
left: 7.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-status[aria-hidden="true"],
|
|
||||||
.scene-metrics[aria-hidden="true"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu {
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: var(--nodedc-font-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__head,
|
|
||||||
.observation-source-menu__foot {
|
|
||||||
padding: 0.85rem 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__head strong,
|
|
||||||
.observation-source-menu__head span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__head strong {
|
|
||||||
margin-top: 0.34rem;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: var(--nodedc-font-size-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__head small,
|
|
||||||
.observation-source-menu__foot {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: var(--nodedc-font-size-xs);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__list {
|
|
||||||
display: grid;
|
|
||||||
max-height: min(28rem, 60vh);
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-option .nodedc-dropdown-option__description {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-option i,
|
|
||||||
.floating-observation-window i,
|
|
||||||
.camera-slot__status i {
|
|
||||||
width: 0.42rem;
|
|
||||||
height: 0.42rem;
|
|
||||||
flex: 0 0 0.42rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
i[data-availability="available"],
|
|
||||||
i[data-availability="streaming"] {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
i[data-availability="connecting"],
|
|
||||||
i[data-availability="declared"] {
|
|
||||||
background: rgb(var(--nodedc-warning-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
i[data-availability="unverified"] {
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
i[data-availability="degraded"],
|
|
||||||
i[data-availability="error"] {
|
|
||||||
background: rgb(var(--nodedc-danger-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__empty,
|
|
||||||
.camera-grid__empty,
|
|
||||||
.observation-media__empty {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
align-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__empty {
|
|
||||||
min-height: 10rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__empty strong,
|
|
||||||
.observation-media__empty strong,
|
|
||||||
.camera-grid__empty strong {
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__empty span,
|
|
||||||
.observation-media__empty span,
|
|
||||||
.camera-grid__empty span {
|
|
||||||
max-width: 24rem;
|
|
||||||
font-size: 0.59rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-source-menu__foot {
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-media__asset,
|
|
||||||
.observation-media__empty {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-media__asset {
|
|
||||||
display: block;
|
|
||||||
object-fit: contain;
|
|
||||||
background: #050608;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recorded-media-player {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #070809;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recorded-media-player__notice {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 18px;
|
|
||||||
color: rgba(247, 248, 244, 0.72);
|
|
||||||
background: #070809;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mse-fmp4-player {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 9rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #050608;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mse-fmp4-player__status {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
align-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgb(5 6 8 / 0.88);
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mse-fmp4-player__status strong {
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mse-fmp4-player__status span {
|
|
||||||
max-width: 24rem;
|
|
||||||
font-size: 0.59rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mse-fmp4-player__status button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.12);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(255 255 255 / 0.06);
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
padding: 0.48rem 0.72rem;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.58rem;
|
|
||||||
font-weight: 760;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mse-fmp4-player__status button:hover {
|
|
||||||
background: rgb(255 255 255 / 0.11);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-media__empty {
|
|
||||||
min-height: 9rem;
|
|
||||||
background: #07080a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-observation-window__status,
|
|
||||||
.floating-observation-window__footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot__head-actions button {
|
|
||||||
display: grid;
|
|
||||||
width: 1.9rem;
|
|
||||||
height: 1.9rem;
|
|
||||||
place-items: center;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgb(255 255 255 / 0.05);
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot__head-actions button:hover {
|
|
||||||
background: rgb(255 255 255 / 0.1);
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot__head-actions .camera-slot__activation {
|
|
||||||
width: auto;
|
|
||||||
min-width: 5.6rem;
|
|
||||||
height: 1.9rem;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.09);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0 0.65rem;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.53rem;
|
|
||||||
font-weight: 760;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot__head-actions .camera-slot__activation:disabled {
|
|
||||||
cursor: wait;
|
|
||||||
opacity: 0.48;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-observation-window__status {
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.54rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-observation-window__footer {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.53rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-observation-window .nodedc-workspace-window__body {
|
|
||||||
background: #06070a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-observation-window.nodedc-material-rim::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-observation-window[data-active="true"] {
|
|
||||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-observation-window--hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline {
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background: rgb(9 10 13 / 0.78);
|
|
||||||
padding: 0.4rem;
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline[data-accumulation="true"] {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__playback {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__playback > .nodedc-select-anchor {
|
|
||||||
display: inline-flex;
|
|
||||||
width: auto;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__transport {
|
|
||||||
width: var(--nodedc-control-height-compact);
|
|
||||||
padding: 0;
|
|
||||||
border-radius: var(--nodedc-radius-circle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__accumulation {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
padding: 0 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__accumulation span,
|
|
||||||
.observation-timeline__accumulation code {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.55rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__accumulation code {
|
|
||||||
min-width: 2.65rem;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__track {
|
|
||||||
width: 100%;
|
|
||||||
height: 0.3rem;
|
|
||||||
appearance: none;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(255 255 255 / 0.08);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__track::-webkit-slider-thumb {
|
|
||||||
width: 0.72rem;
|
|
||||||
height: 0.72rem;
|
|
||||||
appearance: none;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__track::-moz-range-thumb {
|
|
||||||
width: 0.72rem;
|
|
||||||
height: 0.72rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__track:disabled {
|
|
||||||
opacity: 0.46;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__meta {
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 0.12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__meta code,
|
|
||||||
.observation-timeline__meta small {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.52rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__follow {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.38rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0.3rem 0.45rem;
|
|
||||||
font-size: 0.52rem;
|
|
||||||
font-weight: 820;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__follow:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__follow::before {
|
|
||||||
width: 0.4rem;
|
|
||||||
height: 0.4rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__follow[data-active="true"] {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__follow[data-active="true"]::before {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
right: 0.75rem;
|
|
||||||
bottom: 0.75rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-workspace[data-focused="true"] {
|
|
||||||
min-height: 0;
|
|
||||||
grid-template-rows: minmax(0, 1fr);
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
|
|
||||||
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cameras-workspace {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
||||||
overflow: hidden;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-workspace__catalog {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.8rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-grid {
|
|
||||||
display: grid;
|
|
||||||
min-height: 0;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
|
||||||
grid-template-rows: none;
|
|
||||||
grid-auto-rows: minmax(16rem, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-grid[data-count="1"] {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-grid__empty {
|
|
||||||
min-height: 18rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: #07080a;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot {
|
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot header,
|
|
||||||
.camera-slot footer {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot__head-actions,
|
|
||||||
.camera-slot__status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot__status {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.56rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot__body {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot footer {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot footer span {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-slot footer small {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.53rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-timeline {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cameras-workspace[data-focused="true"] {
|
|
||||||
height: 100%;
|
|
||||||
grid-template-rows: minmax(0, 1fr) auto;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cameras-workspace[data-focused="true"] > .workspace-lead,
|
|
||||||
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cameras-workspace[data-focused="true"] .camera-grid {
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.scene-status--top-left {
|
|
||||||
top: 4.2rem;
|
|
||||||
left: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline[data-accumulation="true"] {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__playback {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observation-timeline__playback > .nodedc-button:first-child,
|
|
||||||
.observation-timeline__meta {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-auto-rows: minmax(14rem, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,83 +1 @@
|
|||||||
.scene-tool-window {
|
@import "../../../../packages/spatial-ui/src/scene-windows.css";
|
||||||
--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,484 +1 @@
|
|||||||
.spatial-workspace {
|
@import "../../../../packages/spatial-ui/src/spatial.css";
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 34rem;
|
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
||||||
gap: 0.65rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-toolbar {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-toolbar__actions {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-toolbar__view-switch {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.07);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(255 255 255 / 0.025);
|
|
||||||
padding: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-toolbar__view-switch .nodedc-button {
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-viewport-shell {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: #06070a;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport,
|
|
||||||
.rerun-viewport__canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__canvas[data-presented="false"] {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recorded-session-preloaders {
|
|
||||||
position: fixed;
|
|
||||||
left: -10000px;
|
|
||||||
top: -10000px;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport {
|
|
||||||
--rerun-native-chrome-height: 72px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
|
||||||
panels are overridden: the native top row, recording tab and view tab.
|
|
||||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
|
||||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
|
||||||
.rerun-viewport__canvas {
|
|
||||||
top: calc(-1 * var(--rerun-native-chrome-height));
|
|
||||||
bottom: auto;
|
|
||||||
height: calc(100% + var(--rerun-native-chrome-height));
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__runtime {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__canvas canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__camera-lock {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: calc(46% - 2px);
|
|
||||||
cursor: default;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
|
||||||
.rerun-viewport__canvas {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__notice {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.8rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: rgb(10 11 14 / 0.82);
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__notice strong,
|
|
||||||
.rerun-viewport__notice span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__notice strong {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__notice span {
|
|
||||||
margin-top: 0.18rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.61rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__notice--error {
|
|
||||||
background: rgb(10 11 14 / 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__retry {
|
|
||||||
margin-top: 0.65rem;
|
|
||||||
border: 1px solid var(--station-hairline-strong);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(255 255 255 / 0.08);
|
|
||||||
padding: 0.42rem 0.7rem;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.61rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rerun-viewport__retry:hover,
|
|
||||||
.rerun-viewport__retry:focus-visible {
|
|
||||||
background: rgb(255 255 255 / 0.14);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.busy-indicator {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex: 0 0 1rem;
|
|
||||||
border: 2px solid rgb(255 255 255 / 0.12);
|
|
||||||
border-top-color: var(--nodedc-text-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: viewer-spin 900ms linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes viewer-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-spatial-stage {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #06070a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-spatial-stage__grid {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-axis {
|
|
||||||
position: absolute;
|
|
||||||
right: 2rem;
|
|
||||||
bottom: 5.2rem;
|
|
||||||
width: 4.4rem;
|
|
||||||
height: 4.4rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.53rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-axis::before,
|
|
||||||
.spatial-axis::after {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0.65rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
width: 2.7rem;
|
|
||||||
height: 1px;
|
|
||||||
background: rgb(255 255 255 / 0.2);
|
|
||||||
content: "";
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-axis::before { transform: rotate(-24deg); }
|
|
||||||
.spatial-axis::after { transform: rotate(-90deg); }
|
|
||||||
.spatial-axis span { position: absolute; }
|
|
||||||
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
|
|
||||||
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
|
|
||||||
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
|
|
||||||
|
|
||||||
.empty-spatial-stage__message {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: grid;
|
|
||||||
max-width: 25rem;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 0.62rem;
|
|
||||||
transform: translate(-50%, -58%);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-spatial-stage__icon {
|
|
||||||
display: grid;
|
|
||||||
width: 3.4rem;
|
|
||||||
height: 3.4rem;
|
|
||||||
place-items: center;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgb(255 255 255 / 0.035);
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-spatial-stage__message strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-spatial-stage__message p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.67rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-status,
|
|
||||||
.scene-metrics,
|
|
||||||
.scene-adapter-note,
|
|
||||||
.scene-selection,
|
|
||||||
.scene-timeline {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 3;
|
|
||||||
border: 0;
|
|
||||||
background: rgb(9 10 13 / 0.74);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-status--top-left {
|
|
||||||
top: 0.85rem;
|
|
||||||
left: 0.85rem;
|
|
||||||
display: grid;
|
|
||||||
justify-items: start;
|
|
||||||
gap: 0.42rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
padding: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-status small {
|
|
||||||
max-width: 18rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.57rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-metrics {
|
|
||||||
top: 0.85rem;
|
|
||||||
right: 0.85rem;
|
|
||||||
display: grid;
|
|
||||||
min-width: 10rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-metrics > div {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
border-bottom: 0;
|
|
||||||
padding: 0.38rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-metrics > div:last-child { border-bottom: 0; }
|
|
||||||
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
|
|
||||||
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
|
|
||||||
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
|
|
||||||
|
|
||||||
.scene-adapter-note {
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
max-width: 34rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
padding: 0.75rem 0.9rem;
|
|
||||||
font-size: 0.63rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
transform: translate(-50%, 5.2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
|
|
||||||
|
|
||||||
.scene-selection {
|
|
||||||
right: 0.85rem;
|
|
||||||
bottom: 4.6rem;
|
|
||||||
display: grid;
|
|
||||||
max-width: 19rem;
|
|
||||||
gap: 0.2rem;
|
|
||||||
border-radius: 0.85rem;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-selection span,
|
|
||||||
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
|
|
||||||
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
|
|
||||||
.scene-operation-status-stack {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 11;
|
|
||||||
left: 0.85rem;
|
|
||||||
bottom: 7.15rem;
|
|
||||||
display: grid;
|
|
||||||
justify-items: start;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-operation-status {
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: min(24rem, calc(100% - 1.7rem));
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.42rem;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.07);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(9 10 13 / 0.7);
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
padding: 0.42rem 0.65rem;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.52rem;
|
|
||||||
font-weight: 680;
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-operation-status .busy-indicator {
|
|
||||||
width: 0.66rem;
|
|
||||||
height: 0.66rem;
|
|
||||||
flex-basis: 0.66rem;
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-operation-status--action {
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-operation-status--action:hover,
|
|
||||||
.scene-operation-status--action:focus-visible {
|
|
||||||
border-color: rgb(255 255 255 / 0.14);
|
|
||||||
background: rgb(20 21 25 / 0.82);
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-operation-status--error {
|
|
||||||
color: rgb(var(--nodedc-danger-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline {
|
|
||||||
right: 0.75rem;
|
|
||||||
bottom: 0.75rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline__track {
|
|
||||||
height: 0.3rem;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(255 255 255 / 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline__track span {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
|
|
||||||
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
|
|
||||||
|
|
||||||
.scene-timeline__follow {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.38rem;
|
|
||||||
border-radius: var(--nodedc-radius-circle);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
padding: 0.3rem 0.45rem;
|
|
||||||
font-size: 0.52rem;
|
|
||||||
font-weight: 820;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline__follow::before {
|
|
||||||
width: 0.4rem;
|
|
||||||
height: 0.4rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline__follow[data-active="true"] {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-timeline__follow[data-active="true"]::before {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-contract-strip {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem 1rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.57rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-contract-strip span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.38rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-contract-strip i {
|
|
||||||
width: 0.42rem;
|
|
||||||
height: 0.42rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
|
|
||||||
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.busy-indicator { animation: none; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||||
@@ -498,15 +499,7 @@ function SpatialWorkspace({
|
|||||||
Сброс вида
|
Сброс вида
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/>
|
||||||
Движок
|
|
||||||
</Button>
|
|
||||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
|
||||||
Слои
|
|
||||||
</Button>
|
|
||||||
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
|
||||||
Отображение
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,15 @@ test('active acquisition exposes STOP and Rerun without another START',()=>{
|
|||||||
const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
|
const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
|
||||||
const markup=render(active);
|
const markup=render(active);
|
||||||
assert.match(markup,/Остановить устройство/);
|
assert.match(markup,/Остановить устройство/);
|
||||||
assert.match(markup,/k1-preview-spatial/);
|
assert.match(markup,/rerun-viewport__canvas/);
|
||||||
assert.match(markup,/>Статус</);
|
assert.match(markup,/>Статус</);
|
||||||
assert.match(markup,/>Rerun</);
|
assert.match(markup,/>Пространственная модель</);
|
||||||
|
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
|
||||||
|
assert.ok(markup.includes('data-presented="false"'));
|
||||||
|
assert.ok(!markup.includes('Loading Rerun'));
|
||||||
|
assert.ok(!markup.includes('The data layer'));
|
||||||
|
assert.ok(!markup.includes('data-align="center"'));
|
||||||
|
|
||||||
assert.doesNotMatch(markup,/Восстановить просмотр|nodedc-activity-indicator/);
|
assert.doesNotMatch(markup,/Восстановить просмотр|nodedc-activity-indicator/);
|
||||||
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
|
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
|
||||||
assert.equal(presentation.k1ManualState(active,false).canStart,false);
|
assert.equal(presentation.k1ManualState(active,false).canStart,false);
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import assert from 'node:assert/strict';
|
|||||||
import {before,after,test} from 'node:test';
|
import {before,after,test} from 'node:test';
|
||||||
import {execFileSync} from 'node:child_process';
|
import {execFileSync} from 'node:child_process';
|
||||||
import {createServer} from 'vite';
|
import {createServer} from 'vite';
|
||||||
let server,previewFrames,assertRrd;
|
let server,previewFrames,assertRrd,previewFreshness,previewCamera;
|
||||||
before(async()=>{
|
before(async()=>{
|
||||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||||
|
({previewCamera}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewCamera.ts'));
|
||||||
|
({previewFreshness}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFreshness.ts'));
|
||||||
({previewFrames,assertRrd}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts'));
|
({previewFrames,assertRrd}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts'));
|
||||||
});
|
});
|
||||||
after(async()=>{await server?.close();});
|
after(async()=>{await server?.close();});
|
||||||
@@ -41,3 +43,38 @@ test('partial, oversized, unframed and invalid records never enter the decoder',
|
|||||||
receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(12))));
|
receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(12))));
|
||||||
assert.equal(calls,0);
|
assert.equal(calls,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('source age expires even when metadata repeats, old frames never look live',()=>{
|
||||||
|
let now=10000;const gate=previewFreshness(()=>now);
|
||||||
|
assert.equal(gate.fresh,false);
|
||||||
|
gate.receive({type:'lidar-state',sequence:1,age_ms:250,points:3});
|
||||||
|
assert.equal(gate.fresh,true);assert.equal(gate.points,3);
|
||||||
|
now+=1800;gate.receive({type:'lidar-state',sequence:1,age_ms:0,points:3});
|
||||||
|
assert.equal(gate.fresh,false);
|
||||||
|
gate.receive({type:'lidar-state',sequence:2,age_ms:9000,points:3});
|
||||||
|
assert.equal(gate.fresh,false);
|
||||||
|
gate.receive({type:'lidar-state',sequence:3,age_ms:0,points:4});
|
||||||
|
assert.equal(gate.fresh,true);assert.equal(gate.points,4);
|
||||||
|
assert.throws(()=>gate.receive({type:'lidar-state',sequence:4,age_ms:-1,points:4}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onboard camera admits immediate sourceopen and ignores callbacks after close',()=>{
|
||||||
|
const saved={media:globalThis.MediaSource,create:URL.createObjectURL,revoke:URL.revokeObjectURL};
|
||||||
|
let source,buffer,appended=0,ready=0,failed=0;
|
||||||
|
class Buffer extends EventTarget {updating=false;buffered={length:0};appendBuffer(){appended++;}}
|
||||||
|
class Media extends EventTarget {
|
||||||
|
readyState='open';static isTypeSupported(){return true;}
|
||||||
|
constructor(){super();source=this;}
|
||||||
|
addSourceBuffer(){buffer=new Buffer();return buffer;}
|
||||||
|
}
|
||||||
|
class Video extends EventTarget {currentTime=0;set src(_){source.dispatchEvent(new Event('sourceopen'));}pause(){}load(){}removeAttribute(){}play(){return Promise.resolve();}}
|
||||||
|
globalThis.MediaSource=Media;URL.createObjectURL=()=> 'blob:synthetic';URL.revokeObjectURL=()=>{};
|
||||||
|
try{
|
||||||
|
const video=new Video(),decoder=previewCamera(video,()=>ready++,()=>failed++);
|
||||||
|
decoder.open('video/mp4');decoder.push(new Uint8Array([1,2,3]));
|
||||||
|
assert.equal(appended,1);
|
||||||
|
video.dispatchEvent(new Event('loadeddata'));assert.equal(ready,1);
|
||||||
|
decoder.close();video.dispatchEvent(new Event('loadeddata'));buffer.dispatchEvent(new Event('error'));
|
||||||
|
assert.equal(ready,1);assert.equal(failed,0);
|
||||||
|
}finally{globalThis.MediaSource=saved.media;URL.createObjectURL=saved.create;URL.revokeObjectURL=saved.revoke;}
|
||||||
|
});
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
|||||||
|
|
||||||
test("recording preparation statuses share the viewer's left alignment", async () => {
|
test("recording preparation statuses share the viewer's left alignment", async () => {
|
||||||
const spatialStyles = await readFile(
|
const spatialStyles = await readFile(
|
||||||
new URL("../src/styles/spatial.css", import.meta.url),
|
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const statusStack = spatialStyles.slice(
|
const statusStack = spatialStyles.slice(
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ test("recorded player skips only a proven buffered corrupt timestamp interval",
|
|||||||
|
|
||||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||||
const css = await readFile(
|
const css = await readFile(
|
||||||
new URL("../src/styles/observation.css", import.meta.url),
|
new URL("../../../packages/spatial-ui/src/observation.css", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ test("the spatial header reports raw replay as an active source", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||||
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
|
const css = await readFile(new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url), "utf8");
|
||||||
assert.match(
|
assert.match(
|
||||||
css,
|
css,
|
||||||
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
|
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def provenance():
|
|||||||
"design_guideline_commit": DG_COMMIT,
|
"design_guideline_commit": DG_COMMIT,
|
||||||
"design_guideline_files": guideline_sources(),
|
"design_guideline_files": guideline_sources(),
|
||||||
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||||
|
"shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()},
|
||||||
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
|
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
|
||||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.8.8"
|
VERSION = "0.8.9"
|
||||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||||
from debian import package
|
from debian import package
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# K1 onboard live presentation R12
|
||||||
|
|
||||||
|
## Owner request and composition decision
|
||||||
|
|
||||||
|
The operator starts the enrolled K1 from Fleet and observes the live point cloud
|
||||||
|
and camera from its paired onboard computer. The owner explicitly selected the
|
||||||
|
established direct K1 live-acquisition template for this detail view: shared
|
||||||
|
engine/layer/display tools, accumulation, floating resizable camera, source
|
||||||
|
visibility, metrics and expansion. An independent simplified onboard viewer was
|
||||||
|
rejected because it had already drifted in layout, controls and status meaning.
|
||||||
|
This is reuse within the admitted Fleet sensor-detail surface, not a new product
|
||||||
|
root, navigation change or Design Guideline primitive. The existing manual
|
||||||
|
START/STOP authority remains in the device control card above the shared scene.
|
||||||
|
|
||||||
|
Primitives remain Design Guideline Button/IconButton, SettingsCard, StatusBadge,
|
||||||
|
Inspector, Window and WorkspaceWindow. Shared domain composition is extracted to
|
||||||
|
`packages/spatial-ui`: both the direct host and onboard plugin consume the same
|
||||||
|
controls, timeline, media frame, source picker and styles. Direct live, recorded
|
||||||
|
and LAB source/transport/profile owners remain separate. No recorded blueprint,
|
||||||
|
archive format, physical command or reconnect supervisor policy changes here.
|
||||||
|
|
||||||
|
## Evidence and fault
|
||||||
|
|
||||||
|
Owner's sequence after installing R11: Bluetooth/Wi-Fi enrollment, manual START
|
||||||
|
and reopening Mission Core succeeded. The viewer repeatedly showed upstream
|
||||||
|
welcome/loading chrome, eventually displayed points, had no camera and could
|
||||||
|
present old points after a source pause. This confirms those user-observed
|
||||||
|
behaviors only; it does not establish full live-preview acceptance.
|
||||||
|
|
||||||
|
The native Rerun binary sink returns `None` when there are no new bytes.
|
||||||
|
`RrdSubscriber.run` called `len(payload)` unconditionally, swallowed TypeError
|
||||||
|
and closed the subscription. NodeMediaPeers then closed the whole media peer,
|
||||||
|
including camera delivery. Browser retry constructed another native runtime.
|
||||||
|
A local native SDK reproduction and an actual loopback WebRTC regression now
|
||||||
|
cover idle reads, idle media channels and subsequent new point/camera data.
|
||||||
|
|
||||||
|
A second presentation defect treated existence of the blueprint's stream_time
|
||||||
|
range as proof of lidar data. Preview protocol v2 adds bounded sequence/age/point
|
||||||
|
metadata after native RRD bytes. Only fresh source frames admit the scene.
|
||||||
|
Repeated metadata cannot renew freshness. Old decoded frames are discarded
|
||||||
|
before encoding; a newly opened subscriber does not replay expired cached data.
|
||||||
|
No operator/board wall-clock synchronization is required for the age gate.
|
||||||
|
|
||||||
|
## Implementation boundaries
|
||||||
|
|
||||||
|
- Native runtime lifetime is the mounted scene, separate from its disposable
|
||||||
|
WebRTC media connection. Recovery opens a new recording channel inside the
|
||||||
|
existing runtime. No automatic START, STOP, Wi-Fi or BLE action is added.
|
||||||
|
- Native Following remains owned by the live blueprint. Polling no longer
|
||||||
|
repeatedly pauses and positions its cursor.
|
||||||
|
- Both upstream loading/welcome content and native header/view tabs are masked
|
||||||
|
using the same presentation gate and crop as the established direct viewer.
|
||||||
|
- The heading is “Пространственная модель”; “Статус” is left aligned and its
|
||||||
|
message is centered. Physical action loaders remain inside their button.
|
||||||
|
- Display edits are serialized live-acquisition settings operations. Closing a
|
||||||
|
modeless tool flushes changes and closes unconditionally; failures use the
|
||||||
|
host's existing error channel. LAB and recorded settings are unchanged.
|
||||||
|
- Camera visibility is local presentation, with its decoder kept mounted when
|
||||||
|
hidden. Acquisition and the durable camera producer remain onboard-owned.
|
||||||
|
|
||||||
|
## Validation and acceptance
|
||||||
|
|
||||||
|
Completed at draft time: both TypeScript projects; architecture/focused frontend
|
||||||
|
checks except the deliberately obsolete old viewer-class assertion (updated to
|
||||||
|
assert shared template and masked initial presentation); 12 Python checks across
|
||||||
|
native subscriber and media, including one bounded loopback WebRTC peer with two
|
||||||
|
channels and idle/resume. No real BLE/MQTT commands were issued for these checks.
|
||||||
|
Full Control Station regression: 802/802 passed. Control Station production
|
||||||
|
build passed. Node package build and physical acceptance remain pending.
|
||||||
|
|
||||||
|
R12 packages target Node 0.8.9 and optional K1 0.1.9. Immutable source/artifact
|
||||||
|
identity, installation and acceptance are recorded after packaging.
|
||||||
|
|
||||||
|
Required physical acceptance remains: owner clears Chrome cache before each
|
||||||
|
UI run; enrolled K1 START; both actual points and camera; settings/accumulation;
|
||||||
|
normal/expanded windows and Escape; STOP; pause/recovery; browser reopen without
|
||||||
|
stale live data. The agent's CUA session exposes the in-app browser, not Chrome
|
||||||
|
cache reset. Do not describe that as a completed clean-cache hardware test.
|
||||||
|
|
||||||
|
No Ops publication or Git push is attempted here: prior specific automatic
|
||||||
|
approval rejections remain unresolved. Private raw artifacts remain outside Git.
|
||||||
@@ -35,7 +35,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
|||||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||||
return <div className="sensor-workspace">{device?Detail?<Detail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
|
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
|
||||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './contracts';
|
|||||||
export type * from './enrollment';
|
export type * from './enrollment';
|
||||||
export type * from './rerunHost';
|
export type * from './rerunHost';
|
||||||
export type * from './extensions';
|
export type * from './extensions';
|
||||||
|
export * from '../../spatial-ui/src';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** Native renderer capability supplied by each host, independent of device APIs. */
|
/** Native renderer capability supplied by each host, independent of device APIs. */
|
||||||
export interface LiveRerunViewer {
|
export interface LiveRerunViewer {
|
||||||
start:(source:string|string[]|null,parent:HTMLElement,options:{width:string;height:string;hide_welcome_screen:boolean;enable_history:boolean})=>Promise<void>;
|
start:(source:string|string[]|null,parent:HTMLElement,options:{width:string;height:string;hide_welcome_screen:boolean;enable_history:boolean;theme?:'dark'|'light';allow_fullscreen?:boolean;panel_state_overrides?:Record<string,string>})=>Promise<void>;
|
||||||
open_channel:(name:string)=>{ready:boolean;send_rrd:(bytes:Uint8Array)=>void;close:()=>void};
|
open_channel:(name:string)=>{ready:boolean;send_rrd:(bytes:Uint8Array)=>void;close:()=>void};
|
||||||
override_panel_state:(panel:'top'|'blueprint'|'selection'|'time',state:'hidden'|'collapsed'|'expanded')=>void;
|
override_panel_state:(panel:'top'|'blueprint'|'selection'|'time',state:'hidden'|'collapsed'|'expanded')=>void;
|
||||||
get_active_recording_id:()=>string|null;
|
get_active_recording_id:()=>string|null;
|
||||||
|
get_active_timeline:(id:string)=>string|null;
|
||||||
get_time_range:(id:string,timeline:string)=>{min:number;max:number}|null;
|
get_time_range:(id:string,timeline:string)=>{min:number;max:number}|null;
|
||||||
set_active_timeline:(id:string,timeline:string)=>void;
|
set_active_timeline:(id:string,timeline:string)=>void;
|
||||||
set_current_time:(id:string,timeline:string,time:number)=>void;
|
set_current_time:(id:string,timeline:string,time:number)=>void;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
|
||||||
|
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
export interface ObservationWindowRect {x:number;y:number;width:number;height:number}
|
||||||
|
|
||||||
|
const WINDOW_WIDTH = 336;
|
||||||
|
const WINDOW_HEIGHT = 210;
|
||||||
|
const WINDOW_GAP = 16;
|
||||||
|
const WINDOW_INSET = 18;
|
||||||
|
const TIMELINE_CLEARANCE = 64;
|
||||||
|
|
||||||
|
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||||
|
|
||||||
|
export function shouldCaptureWorkspacePointer(
|
||||||
|
button: number,
|
||||||
|
target: ClosestTarget | null,
|
||||||
|
): boolean {
|
||||||
|
if (button !== 0 || !target) return false;
|
||||||
|
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||||
|
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||||
|
return !target.closest("button, input, select, textarea, a");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialObservationWindowRect(
|
||||||
|
index: number,
|
||||||
|
count = 1,
|
||||||
|
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
||||||
|
): ObservationWindowRect {
|
||||||
|
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
||||||
|
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
||||||
|
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
||||||
|
const maxColumns = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
||||||
|
);
|
||||||
|
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
||||||
|
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
||||||
|
const rowHeight = Math.max(
|
||||||
|
1,
|
||||||
|
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
||||||
|
);
|
||||||
|
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
const column = index % columns;
|
||||||
|
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||||
|
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||||
|
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||||
|
y: Math.max(
|
||||||
|
0,
|
||||||
|
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingMediaWindow({title,subtitle,status,footer,children,index=0,count=1,boundsRef,rect,maximized,active,hidden=false,resizable=true,onRectChange,onMaximizedChange,onActivate,onClose}:{
|
||||||
|
title:string;subtitle?:string;status?:ReactNode;footer?:ReactNode;children:ReactNode;
|
||||||
|
index?:number;count?:number;boundsRef:RefObject<HTMLElement|null>;rect?:ObservationWindowRect;
|
||||||
|
maximized:boolean;active:boolean;hidden?:boolean;resizable?:boolean;
|
||||||
|
onRectChange:(rect:ObservationWindowRect)=>void;onMaximizedChange:(value:boolean)=>void;
|
||||||
|
onActivate:()=>void;onClose:()=>void;
|
||||||
|
}) {
|
||||||
|
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = boundsRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const next = { width: element.clientWidth, height: element.clientHeight };
|
||||||
|
setBounds((current) => (
|
||||||
|
current?.width === next.width && current.height === next.height ? current : next
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [boundsRef]);
|
||||||
|
|
||||||
|
// Camera media admission must not wait for the viewport observer. A source
|
||||||
|
// can arrive in the same commit as a workspace transition; mounting it with
|
||||||
|
// the canonical fallback geometry keeps the browser transport alive until
|
||||||
|
// the real bounds are measured immediately afterward.
|
||||||
|
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
||||||
|
const windowRect = rect ?? initialRect;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceWindow
|
||||||
|
boundsRef={boundsRef}
|
||||||
|
rect={windowRect}
|
||||||
|
onRectChange={onRectChange}
|
||||||
|
maximized={maximized}
|
||||||
|
onMaximizedChange={onMaximizedChange}
|
||||||
|
onActivate={onActivate}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
status={status}
|
||||||
|
footer={footer}
|
||||||
|
minWidth={Math.min(280, windowRect.width)}
|
||||||
|
minHeight={Math.min(190, windowRect.height)}
|
||||||
|
resizable={resizable}
|
||||||
|
active={active}
|
||||||
|
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||||
|
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||||
|
try {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Pointer capture can fail if the browser ended the pointer between
|
||||||
|
// dispatch and capture. The donor's window listeners remain fallback.
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
closeLabel={`Закрыть ${title}`}
|
||||||
|
maximizeLabel={`Развернуть ${title}`}
|
||||||
|
restoreLabel={`Восстановить ${title}`}
|
||||||
|
moveLabel={`Переместить ${title}`}
|
||||||
|
resizeLabel={`Изменить размер ${title}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WorkspaceWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {Dropdown,Icon,type IconName} from '@nodedc/ui-react';
|
||||||
|
export type ObservationSourceModality='point-cloud'|'video'|'image'|'depth';
|
||||||
|
export type ObservationSourceAvailability='unverified'|'declared'|'available'|'connecting'|'streaming'|'degraded'|'unavailable'|'error';
|
||||||
|
export interface SpatialSourceDescriptor {id:string;label:string;modality:ObservationSourceModality;availability:ObservationSourceAvailability;endpointLabel?:string|null;transport:string;previewUrl?:string|null;delivery?:unknown;activation?:{controllable:boolean}|null}
|
||||||
|
export const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||||
|
"point-cloud": "globe",
|
||||||
|
video: "video",
|
||||||
|
image: "image",
|
||||||
|
depth: "image",
|
||||||
|
};
|
||||||
|
|
||||||
|
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||||
|
unverified: "Не подтверждён",
|
||||||
|
declared: "Канал объявлен",
|
||||||
|
available: "Доступен",
|
||||||
|
connecting: "Подключение",
|
||||||
|
streaming: "Эфир",
|
||||||
|
degraded: "Нестабильно",
|
||||||
|
unavailable: "Недоступен",
|
||||||
|
error: "Ошибка",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function observationSourceStatusLabel(source: SpatialSourceDescriptor): string {
|
||||||
|
return availabilityCopy[source.availability];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObservationSourcePicker({
|
||||||
|
sources,
|
||||||
|
visibleSourceIds,
|
||||||
|
pendingSourceIds,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
sources: readonly SpatialSourceDescriptor[];
|
||||||
|
visibleSourceIds: ReadonlySet<string>;
|
||||||
|
pendingSourceIds?: ReadonlySet<string>;
|
||||||
|
onToggle: (sourceId: string) => void | Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
className="scene-source-picker"
|
||||||
|
placement="bottom-start"
|
||||||
|
width={340}
|
||||||
|
offset={10}
|
||||||
|
surfaceRole="dialog"
|
||||||
|
surfaceClassName="observation-source-menu"
|
||||||
|
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||||
|
<button
|
||||||
|
ref={(node) => {
|
||||||
|
setAnchorRef(node);
|
||||||
|
setTriggerRef(node);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="scene-source-picker__trigger"
|
||||||
|
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||||
|
aria-label="Источники данных сцены"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={surfaceId}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<Icon name="database" size={17} />
|
||||||
|
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="observation-source-menu__head">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||||
|
<strong>Источники данных</strong>
|
||||||
|
</div>
|
||||||
|
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||||
|
</header>
|
||||||
|
{sources.length ? (
|
||||||
|
<div className="observation-source-menu__list">
|
||||||
|
{sources.map((source) => {
|
||||||
|
const selected = visibleSourceIds.has(source.id);
|
||||||
|
const pending = pendingSourceIds?.has(source.id) ?? false;
|
||||||
|
const canOpen = Boolean(
|
||||||
|
selected ||
|
||||||
|
source.modality === "point-cloud" ||
|
||||||
|
source.previewUrl ||
|
||||||
|
source.delivery ||
|
||||||
|
source.activation?.controllable,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={source.id}
|
||||||
|
type="button"
|
||||||
|
className="nodedc-dropdown-option observation-source-option"
|
||||||
|
data-selected={selected ? "true" : undefined}
|
||||||
|
aria-pressed={selected}
|
||||||
|
disabled={pending || !canOpen}
|
||||||
|
onClick={() => void onToggle(source.id)}
|
||||||
|
>
|
||||||
|
<span className="nodedc-dropdown-option__icon">
|
||||||
|
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="nodedc-dropdown-option__body">
|
||||||
|
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||||
|
<span className="nodedc-dropdown-option__description">
|
||||||
|
<i data-availability={source.availability} aria-hidden="true" />
|
||||||
|
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="nodedc-dropdown-option__check">
|
||||||
|
{selected ? <Icon name="check" size={15} /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="observation-source-menu__empty">
|
||||||
|
<Icon name="database" size={18} />
|
||||||
|
<strong>Каталог пока пуст</strong>
|
||||||
|
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer className="observation-source-menu__foot">
|
||||||
|
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||||
|
модель оборудования.
|
||||||
|
</footer>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||||
|
|
||||||
|
export function ObservationTimeline({
|
||||||
|
active,
|
||||||
|
sourceCount,
|
||||||
|
mode = "live-only",
|
||||||
|
seekable = false,
|
||||||
|
synchronization = "host-arrival-best-effort",
|
||||||
|
rangeNs = null,
|
||||||
|
currentNs = null,
|
||||||
|
playing = false,
|
||||||
|
onSeek,
|
||||||
|
onPlayingChange,
|
||||||
|
onJumpToEnd,
|
||||||
|
showJumpToEnd = true,
|
||||||
|
playbackRate,
|
||||||
|
onPlaybackRateChange,
|
||||||
|
accumulationSeconds,
|
||||||
|
onAccumulationChange,
|
||||||
|
onAccumulationCommit,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
sourceCount: number;
|
||||||
|
mode?: ObservationTimelineMode;
|
||||||
|
seekable?: boolean;
|
||||||
|
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||||
|
rangeNs?: { min: number; max: number } | null;
|
||||||
|
currentNs?: number | null;
|
||||||
|
playing?: boolean;
|
||||||
|
onSeek?: (timeNs: number) => void;
|
||||||
|
onPlayingChange?: (playing: boolean) => void;
|
||||||
|
onJumpToEnd?: () => void;
|
||||||
|
showJumpToEnd?: boolean;
|
||||||
|
playbackRate?: number;
|
||||||
|
onPlaybackRateChange?: (rate: number) => void;
|
||||||
|
accumulationSeconds?: number;
|
||||||
|
onAccumulationChange?: (value: number) => void;
|
||||||
|
onAccumulationCommit?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||||
|
const buffered = Boolean(
|
||||||
|
seekable &&
|
||||||
|
(mode === "buffered" || mode === "recorded") &&
|
||||||
|
playbackRange,
|
||||||
|
);
|
||||||
|
const elapsedSeconds = playbackRange
|
||||||
|
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||||
|
: 0;
|
||||||
|
const durationSeconds = playbackRange
|
||||||
|
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||||
|
: 0;
|
||||||
|
const synchronizationLabel = {
|
||||||
|
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||||
|
"shared-clock": "Общие часы",
|
||||||
|
"frame-accurate": "Покадровая синхронизация",
|
||||||
|
}[synchronization];
|
||||||
|
const accumulationValue = accumulationSeconds === undefined
|
||||||
|
? null
|
||||||
|
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`observation-timeline ${className}`.trim()}
|
||||||
|
data-active={active ? "true" : undefined}
|
||||||
|
data-mode={mode}
|
||||||
|
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{accumulationValue !== null ? (
|
||||||
|
<div className="observation-timeline__accumulation">
|
||||||
|
<span>Накопление</span>
|
||||||
|
<input
|
||||||
|
className="observation-timeline__track"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
|
step={1}
|
||||||
|
value={accumulationValue}
|
||||||
|
aria-label="Окно накопления облака точек"
|
||||||
|
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||||
|
onChange={(event) =>
|
||||||
|
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||||
|
onPointerUp={onAccumulationCommit}
|
||||||
|
onTouchEnd={onAccumulationCommit}
|
||||||
|
onKeyUp={onAccumulationCommit}
|
||||||
|
onBlur={onAccumulationCommit}
|
||||||
|
/>
|
||||||
|
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="observation-timeline__playback">
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!buffered || !onSeek}
|
||||||
|
aria-label="Перейти к началу"
|
||||||
|
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="observation-timeline__transport"
|
||||||
|
size="compact"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!buffered || !onPlayingChange}
|
||||||
|
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||||
|
icon={<Icon name={playing ? "stop" : "play"} />}
|
||||||
|
onClick={() => onPlayingChange?.(!playing)}
|
||||||
|
/>
|
||||||
|
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||||
|
<Select
|
||||||
|
label="Скорость воспроизведения"
|
||||||
|
value={String(playbackRate)}
|
||||||
|
options={[
|
||||||
|
{ value: "0.5", label: "0,5×" },
|
||||||
|
{ value: "1", label: "1×" },
|
||||||
|
{ value: "2", label: "2×" },
|
||||||
|
]}
|
||||||
|
variant="inline"
|
||||||
|
menuWidth="anchor"
|
||||||
|
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
className="observation-timeline__track"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(durationSeconds, 0.001)}
|
||||||
|
step={0.01}
|
||||||
|
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||||
|
disabled={!buffered || !onSeek}
|
||||||
|
aria-label="Позиция воспроизведения"
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!playbackRange) return;
|
||||||
|
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="observation-timeline__meta">
|
||||||
|
<code>
|
||||||
|
{buffered
|
||||||
|
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||||
|
: active ? "LIVE" : "—:—:—.———"}
|
||||||
|
</code>
|
||||||
|
<small>
|
||||||
|
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{showJumpToEnd ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="observation-timeline__follow"
|
||||||
|
data-active={!buffered && active ? "true" : undefined}
|
||||||
|
disabled={buffered ? !onJumpToEnd : true}
|
||||||
|
onClick={onJumpToEnd}
|
||||||
|
>
|
||||||
|
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAccumulationSeconds(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.min(120, Math.max(0, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAccumulationDuration(value: number): string {
|
||||||
|
const seconds = normalizeAccumulationSeconds(value);
|
||||||
|
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTimelineRange(
|
||||||
|
range: { min: number; max: number } | null | undefined,
|
||||||
|
): { min: number; max: number } | null {
|
||||||
|
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||||
|
if (range.max <= range.min) return null;
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineOffsetSeconds(
|
||||||
|
range: { min: number; max: number },
|
||||||
|
currentNs: number,
|
||||||
|
): number {
|
||||||
|
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||||
|
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimelineDuration(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||||
|
const wholeMinutes = Math.floor(seconds / 60);
|
||||||
|
const remaining = seconds - wholeMinutes * 60;
|
||||||
|
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {Checker,ColorField,ControlRow,Inspector,RangeControl,Select} from '@nodedc/ui-react';
|
||||||
|
import type {SceneSettings,PointColorMode,PointPalette} from './sceneSettings';
|
||||||
|
|
||||||
|
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||||
|
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||||
|
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
|
||||||
|
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
|
||||||
|
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
|
||||||
|
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
|
||||||
|
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
|
||||||
|
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
|
||||||
|
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
|
||||||
|
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
|
||||||
|
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDisplayPatch,flushDisplaySettings,replayPresented=false}:{
|
||||||
|
displayDraft:SceneSettings;stageDisplayPatch:(patch:Partial<SceneSettings>)=>void;
|
||||||
|
commitDisplayPatch:(patch:Partial<SceneSettings>)=>void;flushDisplaySettings:()=>void;replayPresented?:boolean;
|
||||||
|
}) { return (
|
||||||
|
<Inspector
|
||||||
|
defaultOpen={["points"]}
|
||||||
|
singleOpen
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
id: "points",
|
||||||
|
label: "Облако точек",
|
||||||
|
description: "Размер и способ окрашивания",
|
||||||
|
content: (
|
||||||
|
<div className="inspector-control-stack">
|
||||||
|
<div
|
||||||
|
className="scene-settings-commit-field"
|
||||||
|
onPointerUp={flushDisplaySettings}
|
||||||
|
onKeyUp={flushDisplaySettings}
|
||||||
|
onBlur={flushDisplaySettings}
|
||||||
|
>
|
||||||
|
<RangeControl
|
||||||
|
label="Размер точки"
|
||||||
|
value={displayDraft.pointSize}
|
||||||
|
min={0.5}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||||
|
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||||
|
<ControlRow label="Атрибут цвета">
|
||||||
|
<Select
|
||||||
|
variant="split"
|
||||||
|
label="Атрибут цвета"
|
||||||
|
value={displayDraft.colorMode}
|
||||||
|
options={colorModeOptions}
|
||||||
|
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
||||||
|
/>
|
||||||
|
</ControlRow>
|
||||||
|
</div>
|
||||||
|
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||||
|
<ControlRow label="Палитра">
|
||||||
|
<Select
|
||||||
|
variant="split"
|
||||||
|
label="Палитра"
|
||||||
|
value={displayDraft.palette}
|
||||||
|
options={paletteOptions}
|
||||||
|
onChange={(palette) => stageDisplayPatch({ palette })}
|
||||||
|
/>
|
||||||
|
</ControlRow>
|
||||||
|
</div>
|
||||||
|
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
|
||||||
|
<div
|
||||||
|
className="scene-settings-commit-field"
|
||||||
|
onPointerUp={flushDisplaySettings}
|
||||||
|
onKeyUp={flushDisplaySettings}
|
||||||
|
onBlur={flushDisplaySettings}
|
||||||
|
>
|
||||||
|
<ControlRow label="Цвет точек">
|
||||||
|
<ColorField
|
||||||
|
label="Цвет точек"
|
||||||
|
value={displayDraft.customColor}
|
||||||
|
onChange={(customColor) => stageDisplayPatch({ customColor })}
|
||||||
|
/>
|
||||||
|
</ControlRow>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{replayPresented ? (
|
||||||
|
<p className="scene-window-note">
|
||||||
|
Первый новый режим читает индекс архивных точек. Следующие палитры
|
||||||
|
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "history",
|
||||||
|
label: "Накопление и время",
|
||||||
|
description: "История облака и траектория",
|
||||||
|
content: (
|
||||||
|
<div className="inspector-control-stack">
|
||||||
|
<div
|
||||||
|
className="scene-settings-commit-field"
|
||||||
|
onPointerUp={flushDisplaySettings}
|
||||||
|
onKeyUp={flushDisplaySettings}
|
||||||
|
onBlur={flushDisplaySettings}
|
||||||
|
>
|
||||||
|
<RangeControl
|
||||||
|
label="Окно накопления"
|
||||||
|
value={displayDraft.accumulationSeconds}
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
|
step={1}
|
||||||
|
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||||
|
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
||||||
|
<Checker
|
||||||
|
checked={displayDraft.showTrajectory}
|
||||||
|
label="Показывать траекторию"
|
||||||
|
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scene",
|
||||||
|
label: "Окружение сцены",
|
||||||
|
description: "Сетка, подписи и камеры",
|
||||||
|
content: (
|
||||||
|
<div className="inspector-control-stack">
|
||||||
|
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
||||||
|
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
||||||
|
</div>
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||||
|
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
); }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {Checker,Inspector} from '@nodedc/ui-react';
|
||||||
|
import type {SceneSettings} from './sceneSettings';
|
||||||
|
export function SceneLayerControls({sceneSettings,pending=false,applyScenePatch}:{sceneSettings:SceneSettings;pending?:boolean;applyScenePatch:(patch:Partial<SceneSettings>)=>void}) { return (
|
||||||
|
<Inspector
|
||||||
|
defaultOpen={["geometry"]}
|
||||||
|
singleOpen
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
id: "geometry",
|
||||||
|
label: "Геометрия",
|
||||||
|
description: "Облако точек и путь",
|
||||||
|
content: (
|
||||||
|
<div className="inspector-control-stack">
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
|
||||||
|
<Checker disabled={pending} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
||||||
|
</div>
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
|
||||||
|
<Checker disabled={pending} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "frames",
|
||||||
|
label: "Координаты и камеры",
|
||||||
|
description: "Преобразования и области обзора",
|
||||||
|
content: (
|
||||||
|
<div className="inspector-control-stack">
|
||||||
|
<Checker disabled={pending} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||||
|
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "perception",
|
||||||
|
label: "Объекты и маски",
|
||||||
|
description: "Ожидают внешние обработчики",
|
||||||
|
content: (
|
||||||
|
<div className="inspector-control-stack">
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||||
|
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
|
||||||
|
</div>
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||||
|
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
|
||||||
|
</div>
|
||||||
|
<div className="nodedc-field">
|
||||||
|
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
||||||
|
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
); }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {Button,Icon} from '@nodedc/ui-react';
|
||||||
|
export function SpatialToolbarActions({openSource,openLayers,openDisplay}:{openSource:()=>void;openLayers:()=>void;openDisplay:()=>void}) {
|
||||||
|
return <>
|
||||||
|
<Button size="compact" variant="secondary" icon={<Icon name="network"/>} onClick={openSource}>Движок</Button>
|
||||||
|
<Button size="compact" variant="secondary" icon={<Icon name="list"/>} onClick={openLayers}>Слои</Button>
|
||||||
|
<Button size="compact" variant="secondary" icon={<Icon name="sliders"/>} onClick={openDisplay}>Отображение</Button>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import './spatial.css';
|
||||||
|
import './scene-windows.css';
|
||||||
|
import './observation.css';
|
||||||
|
export * from './SceneDisplayControls';
|
||||||
|
export * from './SceneLayerControls';
|
||||||
|
export * from './SpatialToolbarActions';
|
||||||
|
export * from './FloatingMediaWindow';
|
||||||
|
export * from './ObservationTimeline';
|
||||||
|
export * from './sceneSettings';
|
||||||
|
export * from './ObservationSourcePicker';
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
.scene-source-controls {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
top: 0.85rem;
|
||||||
|
left: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-device-controls {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: 0.85rem;
|
||||||
|
left: 50%;
|
||||||
|
max-width: calc(100% - 9rem);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger,
|
||||||
|
.scene-source-control,
|
||||||
|
.scene-focus-exit {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.09);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(12 13 16 / 0.82);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-navigation-hint {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 11;
|
||||||
|
right: 0.85rem;
|
||||||
|
bottom: 4.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.07);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(9 10 13 / 0.7);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
padding: 0.42rem 0.65rem;
|
||||||
|
font-size: 0.52rem;
|
||||||
|
font-weight: 680;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger:hover,
|
||||||
|
.scene-source-control:hover,
|
||||||
|
.scene-focus-exit:hover,
|
||||||
|
.scene-source-picker__trigger[data-active="true"] {
|
||||||
|
border-color: rgb(255 255 255 / 0.18);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-source-picker__trigger > span {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.2rem;
|
||||||
|
right: -0.2rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--nodedc-text-primary);
|
||||||
|
color: #090a0c;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-focus-exit {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 0.85rem;
|
||||||
|
right: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status--top-left {
|
||||||
|
left: 7.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status[aria-hidden="true"],
|
||||||
|
.scene-metrics[aria-hidden="true"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--nodedc-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head,
|
||||||
|
.observation-source-menu__foot {
|
||||||
|
padding: 0.85rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head strong,
|
||||||
|
.observation-source-menu__head span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head strong {
|
||||||
|
margin-top: 0.34rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: var(--nodedc-font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__head small,
|
||||||
|
.observation-source-menu__foot {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: var(--nodedc-font-size-xs);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__list {
|
||||||
|
display: grid;
|
||||||
|
max-height: min(28rem, 60vh);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-option .nodedc-dropdown-option__description {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-option i,
|
||||||
|
.floating-observation-window i,
|
||||||
|
.camera-slot__status i {
|
||||||
|
width: 0.42rem;
|
||||||
|
height: 0.42rem;
|
||||||
|
flex: 0 0 0.42rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="available"],
|
||||||
|
i[data-availability="streaming"] {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="connecting"],
|
||||||
|
i[data-availability="declared"] {
|
||||||
|
background: rgb(var(--nodedc-warning-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="unverified"] {
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
i[data-availability="degraded"],
|
||||||
|
i[data-availability="error"] {
|
||||||
|
background: rgb(var(--nodedc-danger-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty,
|
||||||
|
.camera-grid__empty,
|
||||||
|
.observation-media__empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty {
|
||||||
|
min-height: 10rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty strong,
|
||||||
|
.observation-media__empty strong,
|
||||||
|
.camera-grid__empty strong {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__empty span,
|
||||||
|
.observation-media__empty span,
|
||||||
|
.camera-grid__empty span {
|
||||||
|
max-width: 24rem;
|
||||||
|
font-size: 0.59rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-source-menu__foot {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-media__asset,
|
||||||
|
.observation-media__empty {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-media__asset {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #050608;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorded-media-player {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #070809;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorded-media-player__notice {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
color: rgba(247, 248, 244, 0.72);
|
||||||
|
background: #070809;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mse-fmp4-player {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #050608;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mse-fmp4-player__status {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgb(5 6 8 / 0.88);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mse-fmp4-player__status strong {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mse-fmp4-player__status span {
|
||||||
|
max-width: 24rem;
|
||||||
|
font-size: 0.59rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mse-fmp4-player__status button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.06);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
padding: 0.48rem 0.72rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 760;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mse-fmp4-player__status button:hover {
|
||||||
|
background: rgb(255 255 255 / 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-media__empty {
|
||||||
|
min-height: 9rem;
|
||||||
|
background: #07080a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window__status,
|
||||||
|
.floating-observation-window__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions button {
|
||||||
|
display: grid;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.05);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions button:hover {
|
||||||
|
background: rgb(255 255 255 / 0.1);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions .camera-slot__activation {
|
||||||
|
width: auto;
|
||||||
|
min-width: 5.6rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.09);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.65rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.53rem;
|
||||||
|
font-weight: 760;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions .camera-slot__activation:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window__status {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window__footer {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window .nodedc-workspace-window__body {
|
||||||
|
background: #06070a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window.nodedc-material-rim::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window[data-active="true"] {
|
||||||
|
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-observation-window--hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: rgb(9 10 13 / 0.78);
|
||||||
|
padding: 0.4rem;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline[data-accumulation="true"] {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__playback {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__playback > .nodedc-select-anchor {
|
||||||
|
display: inline-flex;
|
||||||
|
width: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__transport {
|
||||||
|
width: var(--nodedc-control-height-compact);
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__accumulation {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__accumulation span,
|
||||||
|
.observation-timeline__accumulation code {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__accumulation code {
|
||||||
|
min-width: 2.65rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__track {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.3rem;
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__track::-webkit-slider-thumb {
|
||||||
|
width: 0.72rem;
|
||||||
|
height: 0.72rem;
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__track::-moz-range-thumb {
|
||||||
|
width: 0.72rem;
|
||||||
|
height: 0.72rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__track:disabled {
|
||||||
|
opacity: 0.46;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__meta {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__meta code,
|
||||||
|
.observation-timeline__meta small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.52rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.52rem;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow::before {
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow[data-active="true"] {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__follow[data-active="true"]::before {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-workspace[data-focused="true"] {
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
|
||||||
|
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-workspace__catalog {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.8rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||||
|
grid-template-rows: none;
|
||||||
|
grid-auto-rows: minmax(16rem, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid[data-count="1"] {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid__empty {
|
||||||
|
min-height: 18rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #07080a;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot header,
|
||||||
|
.camera-slot footer {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__head-actions,
|
||||||
|
.camera-slot__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__status {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-timeline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace[data-focused="true"] {
|
||||||
|
height: 100%;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace[data-focused="true"] > .workspace-lead,
|
||||||
|
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cameras-workspace[data-focused="true"] .camera-grid {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.scene-status--top-left {
|
||||||
|
top: 4.2rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline[data-accumulation="true"] {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__playback {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observation-timeline__playback > .nodedc-button:first-child,
|
||||||
|
.observation-timeline__meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-auto-rows: minmax(14rem, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector-control-stack { display:grid; gap:1rem; }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type SceneProjection = "3d" | "2d" | "map";
|
||||||
|
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||||
|
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||||
|
|
||||||
|
export interface SceneSettings {
|
||||||
|
projection: SceneProjection;
|
||||||
|
pointSize: number;
|
||||||
|
colorMode: PointColorMode;
|
||||||
|
palette: PointPalette;
|
||||||
|
customColor: string;
|
||||||
|
accumulationSeconds: number;
|
||||||
|
showPoints: boolean;
|
||||||
|
showTrajectory: boolean;
|
||||||
|
showGrid: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
showCameraFrustums: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSceneSettings: SceneSettings = {
|
||||||
|
projection: "3d",
|
||||||
|
pointSize: 2.5,
|
||||||
|
colorMode: "intensity",
|
||||||
|
palette: "turbo",
|
||||||
|
customColor: "#35d7c1",
|
||||||
|
accumulationSeconds: 12,
|
||||||
|
showPoints: true,
|
||||||
|
showTrajectory: true,
|
||||||
|
showGrid: true,
|
||||||
|
showLabels: false,
|
||||||
|
showCameraFrustums: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
.spatial-workspace {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 34rem;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.65rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar__view-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.07);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.025);
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar__view-switch .nodedc-button {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-viewport-shell {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #06070a;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport,
|
||||||
|
.rerun-viewport__canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__canvas[data-presented="false"] {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorded-session-preloaders {
|
||||||
|
position: fixed;
|
||||||
|
left: -10000px;
|
||||||
|
top: -10000px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport {
|
||||||
|
--rerun-native-chrome-height: 72px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
||||||
|
panels are overridden: the native top row, recording tab and view tab.
|
||||||
|
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||||
|
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||||
|
.rerun-viewport__canvas {
|
||||||
|
top: calc(-1 * var(--rerun-native-chrome-height));
|
||||||
|
bottom: auto;
|
||||||
|
height: calc(100% + var(--rerun-native-chrome-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__runtime {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__canvas canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__camera-lock {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: calc(46% - 2px);
|
||||||
|
cursor: default;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||||
|
.rerun-viewport__canvas {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(10 11 14 / 0.82);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice strong,
|
||||||
|
.rerun-viewport__notice span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice strong {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice span {
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.61rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice--error {
|
||||||
|
background: rgb(10 11 14 / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__retry {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
border: 1px solid var(--station-hairline-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.08);
|
||||||
|
padding: 0.42rem 0.7rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.61rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__retry:hover,
|
||||||
|
.rerun-viewport__retry:focus-visible {
|
||||||
|
background: rgb(255 255 255 / 0.14);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-indicator {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 1rem;
|
||||||
|
border: 2px solid rgb(255 255 255 / 0.12);
|
||||||
|
border-top-color: var(--nodedc-text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: viewer-spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes viewer-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #06070a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__grid {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-axis {
|
||||||
|
position: absolute;
|
||||||
|
right: 2rem;
|
||||||
|
bottom: 5.2rem;
|
||||||
|
width: 4.4rem;
|
||||||
|
height: 4.4rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-axis::before,
|
||||||
|
.spatial-axis::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.65rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
width: 2.7rem;
|
||||||
|
height: 1px;
|
||||||
|
background: rgb(255 255 255 / 0.2);
|
||||||
|
content: "";
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-axis::before { transform: rotate(-24deg); }
|
||||||
|
.spatial-axis::after { transform: rotate(-90deg); }
|
||||||
|
.spatial-axis span { position: absolute; }
|
||||||
|
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
|
||||||
|
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
|
||||||
|
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
|
||||||
|
|
||||||
|
.empty-spatial-stage__message {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: grid;
|
||||||
|
max-width: 25rem;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.62rem;
|
||||||
|
transform: translate(-50%, -58%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__icon {
|
||||||
|
display: grid;
|
||||||
|
width: 3.4rem;
|
||||||
|
height: 3.4rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__message strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__message p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status,
|
||||||
|
.scene-metrics,
|
||||||
|
.scene-adapter-note,
|
||||||
|
.scene-selection,
|
||||||
|
.scene-timeline {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
border: 0;
|
||||||
|
background: rgb(9 10 13 / 0.74);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status--top-left {
|
||||||
|
top: 0.85rem;
|
||||||
|
left: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.42rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status small {
|
||||||
|
max-width: 18rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.57rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-metrics {
|
||||||
|
top: 0.85rem;
|
||||||
|
right: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 10rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-metrics > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-bottom: 0;
|
||||||
|
padding: 0.38rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-metrics > div:last-child { border-bottom: 0; }
|
||||||
|
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
|
||||||
|
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
|
||||||
|
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
|
||||||
|
|
||||||
|
.scene-adapter-note {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
max-width: 34rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
font-size: 0.63rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
transform: translate(-50%, 5.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
|
||||||
|
|
||||||
|
.scene-selection {
|
||||||
|
right: 0.85rem;
|
||||||
|
bottom: 4.6rem;
|
||||||
|
display: grid;
|
||||||
|
max-width: 19rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-selection span,
|
||||||
|
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
|
||||||
|
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.scene-operation-status-stack {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 11;
|
||||||
|
left: 0.85rem;
|
||||||
|
bottom: 7.15rem;
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-operation-status {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: min(24rem, calc(100% - 1.7rem));
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.07);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(9 10 13 / 0.7);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
padding: 0.42rem 0.65rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.52rem;
|
||||||
|
font-weight: 680;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-operation-status .busy-indicator {
|
||||||
|
width: 0.66rem;
|
||||||
|
height: 0.66rem;
|
||||||
|
flex-basis: 0.66rem;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-operation-status--action {
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-operation-status--action:hover,
|
||||||
|
.scene-operation-status--action:focus-visible {
|
||||||
|
border-color: rgb(255 255 255 / 0.14);
|
||||||
|
background: rgb(20 21 25 / 0.82);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-operation-status--error {
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline {
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__track {
|
||||||
|
height: 0.3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
|
||||||
|
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
|
||||||
|
|
||||||
|
.scene-timeline__follow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.52rem;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__follow::before {
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__follow[data-active="true"] {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__follow[data-active="true"]::before {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem 1rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.57rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip i {
|
||||||
|
width: 0.42rem;
|
||||||
|
height: 0.42rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
|
||||||
|
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.busy-indicator { animation: none; }
|
||||||
|
}
|
||||||
@@ -3,13 +3,15 @@ import {ActivityIndicator,Button,Icon,IconButton,SettingsCard,StatusBadge} from
|
|||||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||||
import {K1LiveView} from './K1LiveView';
|
import {K1LiveView} from './K1LiveView';
|
||||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||||
import {K1LiveSettings} from './K1LiveSettings';
|
import {K1SceneWindows,type SceneTool} from './K1SceneWindows';
|
||||||
|
import {useK1SceneSettings} from './useK1SceneSettings';
|
||||||
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation';
|
import {k1ConnectionNotice,k1ManualState,k1Status} from './presentation';
|
||||||
import {pendingPreview} from './useK1Preview';
|
import {pendingPreview} from './useK1Preview';
|
||||||
|
|
||||||
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
|
export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise<void>;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){
|
||||||
const [busy,setBusy]=useState(''),[settings,setSettings]=useState(false),[operationError,setOperationError]=useState('');
|
const [busy,setBusy]=useState(''),[tool,setTool]=useState<SceneTool|null>(null),[operationError,setOperationError]=useState('');
|
||||||
const [preview,setPreview]=useState(pendingPreview);
|
const [preview,setPreview]=useState(pendingPreview);
|
||||||
|
const scene=useK1SceneSettings(device,transport,failure);
|
||||||
const running=useRef(false);
|
const running=useRef(false);
|
||||||
const failedAction=useRef('');
|
const failedAction=useRef('');
|
||||||
const streaming=device.snapshot.acquisition==='streaming';
|
const streaming=device.snapshot.acquisition==='streaming';
|
||||||
@@ -27,15 +29,15 @@ export function K1Detail({device,transport,enabled,back,refresh,failure,createRe
|
|||||||
return <div className="sensor-content">
|
return <div className="sensor-content">
|
||||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
|
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
|
||||||
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
<SettingsCard title={device.name} description="Ручной запуск камеры и лидара на бортовом компьютере. Исходные данные записи сохраняются на БК."
|
||||||
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setSettings(value=>!value)}><Icon name="settings"/></IconButton>}>
|
actions={manual.connected&&<IconButton label="Настройки устройства" disabled={!!pending} onClick={()=>setTool('display')}><Icon name="settings"/></IconButton>}>
|
||||||
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart} aria-busy={pending==='start'}
|
{manual.connected&&(!manual.showStop||pending==='start')&&<Button variant="primary" disabled={!!pending||!manual.canStart} aria-busy={pending==='start'}
|
||||||
icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
|
icon={pending==='start'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('start')}>{pending==='start'?'Запускаем устройство':'Инициировать запуск'}</Button>}
|
||||||
{manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
|
{manual.showStop&&pending!=='start'&&<Button disabled={!manual.connected||!!pending||(!streaming&&!device.control?.can_stop)} aria-busy={pending==='stop'}
|
||||||
icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
|
icon={pending==='stop'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('stop')}>{pending==='stop'?'Останавливаем устройство':'Остановить устройство'}</Button>}
|
||||||
{!manual.connected&&device.control?.can_verify&&<Button disabled={!enabled||!!pending} aria-busy={pending==='verify'} icon={pending==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
|
{!manual.connected&&device.control?.can_verify&&<Button disabled={!enabled||!!pending} aria-busy={pending==='verify'} icon={pending==='verify'?<ActivityIndicator size="compact"/>:undefined} onClick={()=>void act('verify')}>Проверить состояние K1</Button>}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<SettingsCard title="Статус" align="center" description={streaming?`${summary} ${preview.lidar}. ${preview.camera}.`:summary} role="status" aria-live="polite"/>
|
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></SettingsCard>
|
||||||
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!pending} refresh={refresh} failure={failure}/>}
|
{manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
|
||||||
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview}/>}
|
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled} scene={scene} openTool={setTool}/>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import {useState} from 'react';
|
|
||||||
import {Button,Select,SettingsCard,Switch,TextField} from '@nodedc/ui-react';
|
|
||||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
|
||||||
|
|
||||||
export interface LiveSettings {
|
|
||||||
point_size:number; accumulation_seconds:number;
|
|
||||||
color_mode:'intensity'|'height'|'distance'|'rgb'|'class';
|
|
||||||
palette:'turbo'|'viridis'|'plasma'|'grayscale'|'custom'; custom_color:string;
|
|
||||||
show_points:boolean;show_trajectory:boolean;show_grid:boolean;
|
|
||||||
show_detections_2d:boolean;show_segmentation:boolean;show_cuboids_3d:boolean;
|
|
||||||
}
|
|
||||||
const defaults:LiveSettings={point_size:2.5,accumulation_seconds:12,color_mode:'intensity',palette:'turbo',custom_color:'#f7f8f4',show_points:true,show_trajectory:true,show_grid:true,show_detections_2d:false,show_segmentation:false,show_cuboids_3d:false};
|
|
||||||
|
|
||||||
export function K1LiveSettings({device,transport,enabled,refresh,failure}:{device:Sensor;transport:SensorTransport;enabled:boolean;refresh:()=>Promise<void>;failure:(error:unknown)=>void}){
|
|
||||||
const [settings,setSettings]=useState<LiveSettings>({...defaults,...device.live_settings});
|
|
||||||
const [busy,setBusy]=useState(false);
|
|
||||||
const valid=Number.isFinite(settings.point_size)&&settings.point_size>=0.5&&settings.point_size<=12&&Number.isFinite(settings.accumulation_seconds)&&settings.accumulation_seconds>=0&&settings.accumulation_seconds<=120;
|
|
||||||
async function apply(){setBusy(true);failure(null);try{await perform(transport,device,'option',{profile:'live-acquisition',settings});await refresh();}catch(error){failure(error);}finally{setBusy(false);}}
|
|
||||||
return <SettingsCard title="Живой просмотр" description="Настройки текущего потока с БК. Воспроизведение записей и результаты LAB настраиваются отдельно.">
|
|
||||||
<div className="sensor-fields">
|
|
||||||
<TextField label="Размер точек" type="number" min={0.5} max={12} step={0.5} value={String(settings.point_size)} disabled={busy} onChange={event=>setSettings(v=>({...v,point_size:Number(event.target.value)}))}/>
|
|
||||||
<TextField label="Накопление, с" type="number" min={0} max={120} step={1} value={String(settings.accumulation_seconds)} disabled={busy} onChange={event=>setSettings(v=>({...v,accumulation_seconds:Number(event.target.value)}))}/>
|
|
||||||
<Select label="Цвет точек" value={settings.color_mode} disabled={busy} options={[{value:'intensity',label:'Интенсивность'},{value:'height',label:'Высота'},{value:'distance',label:'Расстояние'},{value:'rgb',label:'Цвет камеры'},{value:'class',label:'Класс'}]} onChange={value=>setSettings(v=>({...v,color_mode:value as LiveSettings['color_mode']}))}/>
|
|
||||||
<Select label="Палитра" value={settings.palette} disabled={busy} options={[{value:'turbo',label:'Turbo'},{value:'viridis',label:'Viridis'},{value:'plasma',label:'Plasma'},{value:'grayscale',label:'Оттенки серого'}]} onChange={value=>setSettings(v=>({...v,palette:value as LiveSettings['palette']}))}/>
|
|
||||||
</div>
|
|
||||||
<div className="sensor-actions"><Switch label="Точки" checked={settings.show_points} disabled={busy} onChange={value=>setSettings(v=>({...v,show_points:value}))}/><Switch label="Траектория" checked={settings.show_trajectory} disabled={busy} onChange={value=>setSettings(v=>({...v,show_trajectory:value}))}/><Switch label="Сетка" checked={settings.show_grid} disabled={busy} onChange={value=>setSettings(v=>({...v,show_grid:value}))}/><Button disabled={!enabled||busy||!valid} onClick={()=>void apply()}>Применить</Button></div>
|
|
||||||
</SettingsCard>;
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,69 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {Icon,IconButton,SettingsCard} from '@nodedc/ui-react';
|
import { Icon, IconButton, SettingsCard, StatusBadge } from '@nodedc/ui-react';
|
||||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
import { FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
|
||||||
import type { Sensor, SensorTransport } from './runtime';
|
import type { Sensor, SensorTransport } from './runtime';
|
||||||
import {useK1Preview,type PreviewStatus} from './useK1Preview';
|
import type { K1SceneState, SceneTool } from './K1SceneWindows';
|
||||||
|
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
|
||||||
import './livePreview.css';
|
import './livePreview.css';
|
||||||
|
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool }: {
|
||||||
export function K1LiveView({device,transport,createRerunHost,onStatus}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onStatus:(value:PreviewStatus)=>void}){
|
device: Sensor;
|
||||||
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
transport: SensorTransport;
|
||||||
const [expanded,setExpanded]=useState(false);
|
createRerunHost: RerunHostFactory;
|
||||||
const [generation,setGeneration]=useState(0),[status,setStatus]=useState<PreviewStatus|null>(null);
|
onStatus: (value: PreviewStatus) => void;
|
||||||
|
enabled: boolean;
|
||||||
|
scene: K1SceneState;
|
||||||
|
openTool: (tool: SceneTool) => void;
|
||||||
|
}) {
|
||||||
|
const spatial = useRef<HTMLDivElement>(null), viewport = useRef<HTMLDivElement>(null), video = useRef<HTMLVideoElement>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false), [cameraMaximized, setCameraMaximized] = useState(false), [cameraRect, setCameraRect] = useState<ObservationWindowRect>();
|
||||||
|
const [visible, setVisible] = useState(() => new Set(['lidar', 'camera']));
|
||||||
|
const [generation, setGeneration] = useState(0), [status, setStatus] = useState<PreviewStatus>(pendingPreview);
|
||||||
const attempts = useRef(0);
|
const attempts = useRef(0);
|
||||||
const report = useCallback((value: PreviewStatus) => { setStatus(value); onStatus(value); }, [onStatus]);
|
const report = useCallback((value: PreviewStatus) => { setStatus(value); onStatus(value); }, [onStatus]);
|
||||||
useK1Preview(device,transport,createRerunHost,spatial,video,report,generation);
|
useK1Preview(device, transport, createRerunHost, spatial, video, report, generation, enabled);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(!status?.retry)return;
|
if (!status.retry || !enabled)
|
||||||
|
return;
|
||||||
const timer = setTimeout(() => setGeneration(value => value + 1), Math.min(30000, 2000 * 2 ** Math.min(attempts.current++, 4)));
|
const timer = setTimeout(() => setGeneration(value => value + 1), Math.min(30000, 2000 * 2 ** Math.min(attempts.current++, 4)));
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
},[status?.retry]);
|
}, [status.retry, enabled]);
|
||||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
useEffect(() => { if (!status.presented)
|
||||||
return <SettingsCard title="Rerun" className={expanded?'k1-preview sensor-viewer-expanded':'k1-preview'}
|
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
|
||||||
actions={<IconButton label={expanded?'Свернуть просмотр':'Развернуть просмотр'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton>}>
|
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||||
<div className="k1-preview-spatial" ref={spatial}/>
|
setExpanded(false);
|
||||||
<video className="k1-preview-camera" hidden={status?.camera!=='Камера: изображение поступает'} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
setCameraMaximized(false);
|
||||||
|
} }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []);
|
||||||
|
const sources: SpatialSourceDescriptor[] = [
|
||||||
|
{ id: 'lidar', label: 'Облако точек', modality: 'point-cloud', availability: status.presented ? 'streaming' : 'connecting', transport: 'Бортовой компьютер' },
|
||||||
|
{ id: 'camera', label: 'Камера K1', modality: 'video', availability: status.cameraPresented ? 'streaming' : 'connecting', transport: 'Бортовой компьютер', delivery: true },
|
||||||
|
];
|
||||||
|
const toggle = (id: string) => setVisible(current => { const next = new Set(current); if (next.has(id))
|
||||||
|
next.delete(id);
|
||||||
|
else
|
||||||
|
next.add(id); return next; });
|
||||||
|
return <SettingsCard title="Пространственная модель" className={expanded ? 'k1-preview sensor-viewer-expanded' : 'k1-preview'} actions={<IconButton label={expanded ? 'Свернуть просмотр' : 'Развернуть просмотр'} onClick={() => setExpanded(value => !value)}><Icon name={expanded ? 'minimize' : 'expand'}/></IconButton>}>
|
||||||
|
<div className="spatial-workspace" data-focused={cameraMaximized ? 'true' : undefined}>
|
||||||
|
<div className="spatial-toolbar" data-viewer-controls="v1"><div className="spatial-toolbar__actions"><SpatialToolbarActions openSource={() => openTool('source')} openLayers={() => openTool('layers')} openDisplay={() => openTool('display')}/></div></div>
|
||||||
|
<div ref={viewport} className="spatial-viewport-shell" data-media-maximized={cameraMaximized ? 'true' : undefined}>
|
||||||
|
<div className="rerun-viewport" data-status={status.presented ? 'ready' : 'loading'}>
|
||||||
|
<div className="rerun-viewport__canvas" data-presented={status.presented && visible.has('lidar') ? 'true' : 'false'} ref={spatial}/>
|
||||||
|
</div>
|
||||||
|
{!cameraMaximized && <div className="scene-source-controls"><ObservationSourcePicker sources={sources} visibleSourceIds={visible} onToggle={toggle}/></div>}
|
||||||
|
<div className="scene-status scene-status--top-left" aria-hidden={cameraMaximized}>
|
||||||
|
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span><StatusBadge tone={status.presented ? 'success' : 'neutral'}>{status.presented ? 'Визуализатор готов' : status.retry ? 'Восстановление связи' : 'Ожидаем свежие данные'}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={cameraMaximized}>
|
||||||
|
<div><span>КАДР/С</span><strong>{status.presented ? (device.frames?.pcl_fps ?? 0).toFixed(1) : '—'}</strong></div>
|
||||||
|
<div><span>До публикации</span><strong>{status.presented && device.frames?.mqtt_to_publish_ms != null ? device.frames.mqtt_to_publish_ms.toFixed(1) : '—'}<small> мс</small></strong></div>
|
||||||
|
<div><span>Точек в кадре</span><strong>{status.presented ? status.points.toLocaleString('ru-RU') : '—'}</strong></div>
|
||||||
|
</div>
|
||||||
|
{status.presented && !cameraMaximized && <div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
||||||
|
{!cameraMaximized && <ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds => scene.stage({ accumulationSeconds })} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
|
||||||
|
<FloatingMediaWindow title="Камера K1" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={() => { }} onClose={() => { setCameraMaximized(false); setVisible(current => { const next = new Set(current); next.delete('camera'); return next; }); }} status={<span className="floating-observation-window__status">{status.cameraPresented ? 'Эфир' : 'Ожидание'}</span>} footer={<span className="floating-observation-window__footer"><span>Бортовой компьютер</span><span>Эфир без буфера</span></span>}>
|
||||||
|
<video className="observation-media__asset" style={{ visibility: status.cameraPresented ? 'visible' : 'hidden' }} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
||||||
|
{!status.cameraPresented && <div className="observation-media__empty k1-camera-notice" role="status"><Icon name="video"/><span>{status.camera}</span></div>}
|
||||||
|
</FloatingMediaWindow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SettingsCard>;
|
</SettingsCard>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ControlRow, Inspector, Window } from '@nodedc/ui-react';
|
||||||
|
import { SceneDisplayControls, SceneLayerControls } from '@mission-core/sensor-sdk';
|
||||||
|
import type { useK1SceneSettings } from './useK1SceneSettings';
|
||||||
|
export type SceneTool = 'source' | 'layers' | 'display';
|
||||||
|
export type K1SceneState = ReturnType<typeof useK1SceneSettings>;
|
||||||
|
export function K1SceneWindows({ tool, close, scene, connected }: {
|
||||||
|
tool: SceneTool | null;
|
||||||
|
close: () => void;
|
||||||
|
scene: K1SceneState;
|
||||||
|
connected: boolean;
|
||||||
|
}) {
|
||||||
|
const title = tool === 'source' ? 'Визуальный движок' : tool === 'layers' ? 'Слои сцены' : 'Отображение';
|
||||||
|
return <Window open={tool !== null} title={title} placement="end" draggable closeOnBackdrop={false} closeOnEscape={false} lockBodyScroll={false} trapFocus={false} className={`scene-tool-window scene-tool-window--${tool === 'source' ? 'source' : tool === 'layers' ? 'layers' : 'display'}`} onClose={() => { scene.flush(); close(); }}>
|
||||||
|
{tool === 'display' && <SceneDisplayControls displayDraft={scene.draft} stageDisplayPatch={scene.stage} commitDisplayPatch={scene.commit} flushDisplaySettings={scene.flush}/>}
|
||||||
|
{tool === 'layers' && <SceneLayerControls sceneSettings={scene.draft} pending={scene.pending} applyScenePatch={scene.commit}/>}
|
||||||
|
{tool === 'source' && <Inspector defaultOpen={['connection']} singleOpen sections={[{ id: 'connection', label: 'Подключение', description: 'Живые данные с бортового компьютера', content: <div className="inspector-control-stack">
|
||||||
|
<ControlRow label="Состояние">{connected ? 'Бортовой компьютер доступен' : 'Нет связи с бортовым компьютером'}</ControlRow>
|
||||||
|
<ControlRow label="Источник">Камера и лидар K1</ControlRow>
|
||||||
|
<ControlRow label="Режим">Живой просмотр</ControlRow>
|
||||||
|
<p className="scene-window-note">Потоки открываются автоматически после запуска устройства. Запись сохраняется на бортовом компьютере.</p>
|
||||||
|
</div> }]}/>}
|
||||||
|
</Window>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
/* Host allocation only; spatial controls and media geometry come from spatial-ui. */
|
||||||
.k1-preview {min-width:0;width:100%}
|
.k1-preview {min-width:0;width:100%}
|
||||||
.k1-preview-spatial {position:relative;min-width:0;width:100%;height:clamp(420px,60vh,800px);overflow:hidden}
|
.k1-preview .spatial-workspace {height:clamp(540px,72vh,900px)}
|
||||||
.k1-preview-spatial iframe {display:block;position:absolute;inset:0;width:100%;height:100%;border:0}
|
.k1-preview.sensor-viewer-expanded .spatial-workspace {height:calc(100vh - 150px)}
|
||||||
.k1-preview-camera {display:block;width:100%;height:clamp(220px,32vh,360px);object-fit:contain}
|
.k1-camera-notice {position:absolute;inset:0}
|
||||||
.k1-preview-camera[hidden] {display:none}
|
.k1-preview-status {margin:0;text-align:center;color:var(--nodedc-text-muted);font-size:var(--nodedc-font-size-xs)}
|
||||||
.k1-preview.sensor-viewer-expanded .k1-preview-spatial {height:calc(100vh - 150px)}
|
|
||||||
|
|||||||
@@ -1,38 +1,71 @@
|
|||||||
/** One MSE decoder per preview; the onboard producer owns camera activation. */
|
/** One MSE decoder per preview; the onboard producer owns camera activation. */
|
||||||
export function previewCamera(video: HTMLVideoElement, ready: () => void, failed: () => void) {
|
export function previewCamera(video: HTMLVideoElement, ready: () => void, failed: () => void) {
|
||||||
let active = true, media: MediaSource | undefined, buffer: SourceBuffer | undefined, url: string | undefined;
|
let active = true, media: MediaSource | undefined, buffer: SourceBuffer | undefined, url: string | undefined;
|
||||||
|
const fail = () => { if (active) failed(); };
|
||||||
let pending: Uint8Array<ArrayBuffer>[] = [], bytes = 0;
|
let pending: Uint8Array<ArrayBuffer>[] = [], bytes = 0;
|
||||||
const append = () => {
|
const append = () => {
|
||||||
if(!active||!buffer||buffer.updating||media?.readyState!=='open')return;
|
if (!active || !buffer || buffer.updating || media?.readyState !== 'open')
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
if(buffer.buffered.length&&video.currentTime-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),video.currentTime-5);return;}
|
if (buffer.buffered.length && video.currentTime - buffer.buffered.start(0) > 8) {
|
||||||
const next=pending.shift();if(next){bytes-=next.byteLength;buffer.appendBuffer(next);}
|
buffer.remove(buffer.buffered.start(0), video.currentTime - 5);
|
||||||
}catch{failed();}
|
return;
|
||||||
|
}
|
||||||
|
const next = pending.shift();
|
||||||
|
if (next) {
|
||||||
|
bytes -= next.byteLength;
|
||||||
|
buffer.appendBuffer(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const decoded=()=>{if(active)ready();};
|
const decoded = () => { if (active)
|
||||||
|
ready(); };
|
||||||
video.addEventListener('loadeddata', decoded);
|
video.addEventListener('loadeddata', decoded);
|
||||||
return {
|
return {
|
||||||
open(mime: string) {
|
open(mime: string) {
|
||||||
if(media||typeof MediaSource==='undefined'||!MediaSource.isTypeSupported(mime))throw new Error('Camera format unavailable');
|
if (media || typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported(mime))
|
||||||
media=new MediaSource();url=URL.createObjectURL(media);video.src=url;
|
throw new Error('Camera format unavailable');
|
||||||
|
media = new MediaSource();
|
||||||
|
url = URL.createObjectURL(media);
|
||||||
media.addEventListener('sourceopen', () => {
|
media.addEventListener('sourceopen', () => {
|
||||||
if(!active)return;
|
if (!active)
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
buffer = media!.addSourceBuffer(mime);
|
buffer = media!.addSourceBuffer(mime);
|
||||||
buffer.addEventListener('error',failed);
|
buffer.addEventListener('error', fail);
|
||||||
buffer.addEventListener('updateend', () => {
|
buffer.addEventListener('updateend', () => {
|
||||||
if(!active)return;
|
if (!active)
|
||||||
if(buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-video.currentTime>1)video.currentTime=Math.max(0,end-0.2);void video.play().catch(()=>{});}
|
return;
|
||||||
|
if (buffer!.buffered.length) {
|
||||||
|
const end = buffer!.buffered.end(buffer!.buffered.length - 1);
|
||||||
|
if (end - video.currentTime > 1)
|
||||||
|
video.currentTime = Math.max(0, end - 0.2);
|
||||||
|
void video.play().catch(() => { });
|
||||||
|
}
|
||||||
append();
|
append();
|
||||||
});append();
|
});
|
||||||
}catch{failed();}
|
append();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
// Installing the listener first also handles immediate sourceopen on reuse.
|
||||||
|
video.src = url;
|
||||||
},
|
},
|
||||||
push(payload: Uint8Array<ArrayBuffer>) {
|
push(payload: Uint8Array<ArrayBuffer>) {
|
||||||
if(!media)throw new Error('Camera metadata missing');
|
if (!media)
|
||||||
bytes+=payload.byteLength;if(bytes>8*1024*1024)throw new Error('Camera preview backlog');
|
throw new Error('Camera metadata missing');
|
||||||
pending.push(payload);append();
|
bytes += payload.byteLength;
|
||||||
|
if (bytes > 8 * 1024 * 1024)
|
||||||
|
throw new Error('Camera preview backlog');
|
||||||
|
pending.push(payload);
|
||||||
|
append();
|
||||||
},
|
},
|
||||||
close(){active=false;pending=[];bytes=0;video.removeEventListener('loadeddata',decoded);video.pause();video.removeAttribute('src');video.load();if(url)URL.revokeObjectURL(url);},
|
close() { active = false; pending = []; bytes = 0; video.removeEventListener('loadeddata', decoded); video.pause(); video.removeAttribute('src'); video.load(); if (url)
|
||||||
|
URL.revokeObjectURL(url); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
|
/** Ordered data-channel fragments are reassembled before a decoder sees them. */
|
||||||
export const MEDIA_PROTOCOL='missioncore.node-preview/v1';
|
export const MEDIA_PROTOCOL='missioncore.node-preview/v2';
|
||||||
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
|
const MAX_PAYLOAD=8*1024*1024,FRAGMENT_BYTES=16384;
|
||||||
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||||
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
|
let pending:Uint8Array<ArrayBuffer>|null=null,offset=0;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** Source-age gate, independent of wall-clock skew between operator and board. */
|
||||||
|
export function previewFreshness(now: () => number = () => performance.now()) {
|
||||||
|
let sequence = 0, expires = 0, points = 0;
|
||||||
|
return {
|
||||||
|
receive(value: unknown) {
|
||||||
|
const frame = value as {
|
||||||
|
type?: string;
|
||||||
|
sequence?: number;
|
||||||
|
age_ms?: number | null;
|
||||||
|
points?: number;
|
||||||
|
};
|
||||||
|
if (frame?.type !== 'lidar-state' || !Number.isSafeInteger(frame.sequence) || frame.sequence! < 0 ||
|
||||||
|
!Number.isSafeInteger(frame.points) || frame.points! < 0 ||
|
||||||
|
(frame.age_ms !== null && (!Number.isFinite(frame.age_ms) || frame.age_ms! < 0)))
|
||||||
|
throw new Error('Invalid live frame state');
|
||||||
|
if (frame.sequence! <= sequence)
|
||||||
|
return;
|
||||||
|
sequence = frame.sequence!;
|
||||||
|
points = frame.points!;
|
||||||
|
expires = frame.age_ms === null ? 0 : now() + Math.max(0, 2000 - frame.age_ms!);
|
||||||
|
},
|
||||||
|
get fresh() { return sequence > 0 && now() < expires; },
|
||||||
|
get points() { return points; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,83 +1,198 @@
|
|||||||
import {useEffect,type RefObject} from 'react';
|
import { useEffect, useState, type RefObject } from 'react';
|
||||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
|
||||||
import { perform, type Sensor, type SensorTransport } from './runtime';
|
import { perform, type Sensor, type SensorTransport } from './runtime';
|
||||||
import { assertRrd, MEDIA_PROTOCOL, previewFrames } from './previewFrames';
|
import { assertRrd, MEDIA_PROTOCOL, previewFrames } from './previewFrames';
|
||||||
import { previewCamera } from './previewCamera';
|
import { previewCamera } from './previewCamera';
|
||||||
|
import { previewFreshness } from './previewFreshness';
|
||||||
export type PreviewStatus={lidar:string;camera:string;retry:boolean};
|
export type PreviewStatus = {
|
||||||
export const pendingPreview:PreviewStatus={lidar:'Лидар: ожидаем данные',camera:'Камера: ожидаем изображение',retry:false};
|
lidar: string;
|
||||||
|
camera: string;
|
||||||
|
retry: boolean;
|
||||||
|
presented: boolean;
|
||||||
|
cameraPresented: boolean;
|
||||||
|
points: number;
|
||||||
|
};
|
||||||
|
export const pendingPreview: PreviewStatus = { lidar: 'Лидар: ожидаем данные', camera: 'Камера: ожидаем изображение', retry: false, presented: false, cameraPresented: false, points: 0 };
|
||||||
function privateCandidate(sdp: string): string {
|
function privateCandidate(sdp: string): string {
|
||||||
return sdp.split('\r\n').filter(line => {
|
return sdp.split('\r\n').filter(line => {
|
||||||
if(!line.startsWith('a=candidate:'))return true;
|
if (!line.startsWith('a=candidate:'))
|
||||||
|
return true;
|
||||||
const fields = line.split(' '), ip = fields[4] ?? '', parts = ip.split('.').map(Number);
|
const fields = line.split(' '), ip = fields[4] ?? '', parts = ip.split('.').map(Number);
|
||||||
return fields[7] === 'host' && (ip.endsWith('.local') || (parts.length === 4 && parts.every(v => Number.isInteger(v) && v >= 0 && v <= 255) &&
|
return fields[7] === 'host' && (ip.endsWith('.local') || (parts.length === 4 && parts.every(v => Number.isInteger(v) && v >= 0 && v <= 255) &&
|
||||||
(parts[0] === 10 || parts[0] === 127 || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127))));
|
(parts[0] === 10 || parts[0] === 127 || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127))));
|
||||||
}).join('\r\n');
|
}).join('\r\n');
|
||||||
}
|
}
|
||||||
|
export function useK1Preview(device: Sensor, transport: SensorTransport, createRerunHost: RerunHostFactory, spatial: RefObject<HTMLDivElement | null>, video: RefObject<HTMLVideoElement | null>, onStatus: (value: PreviewStatus) => void, generation = 0, enabled = true) {
|
||||||
export function useK1Preview(device:Sensor,transport:SensorTransport,createRerunHost:RerunHostFactory,
|
const [native, setNative] = useState<LiveRerunViewer | null>(null);
|
||||||
spatial:RefObject<HTMLDivElement|null>,video:RefObject<HTMLVideoElement|null>,onStatus:(value:PreviewStatus)=>void,generation=0){
|
// The native runtime belongs to the view. Media recovery opens a new recording
|
||||||
|
// channel inside it; it never downloads/recreates the WASM iframe on each retry.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active=true,failed=false,peerID:string|undefined,viewClose:(()=>void)|undefined;
|
let active = true;
|
||||||
let keepalive:ReturnType<typeof setInterval>|undefined,follow:ReturnType<typeof setInterval>|undefined,iceTimeout:ReturnType<typeof setTimeout>|undefined;
|
|
||||||
let status={...pendingPreview};onStatus(status);
|
|
||||||
const update=(patch:Partial<PreviewStatus>)=>{if(active){status={...status,...patch};onStatus(status);}};
|
|
||||||
const host = createRerunHost(spatial.current!);
|
const host = createRerunHost(spatial.current!);
|
||||||
|
void host.ready.then(async ({ viewer, mount }) => {
|
||||||
|
if (!active)
|
||||||
|
return;
|
||||||
|
await viewer.start(null, mount, { width: '100%', height: '100%', theme: 'dark', hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
|
||||||
|
panel_state_overrides: { top: 'Hidden', blueprint: 'Hidden', selection: 'Hidden', time: 'Hidden' } });
|
||||||
|
if (!active)
|
||||||
|
return;
|
||||||
|
for (const panel of ['top', 'blueprint', 'selection', 'time'] as const)
|
||||||
|
viewer.override_panel_state(panel, 'hidden');
|
||||||
|
setNative(viewer);
|
||||||
|
}).catch(() => { if (active)
|
||||||
|
onStatus({ ...pendingPreview, lidar: 'Визуализатор не загрузился. Обновите страницу.' }); });
|
||||||
|
return () => { active = false; setNative(null); host.dispose(); };
|
||||||
|
}, [createRerunHost, spatial, onStatus]);
|
||||||
|
useEffect(() => {
|
||||||
|
onStatus(enabled ? { ...pendingPreview } : { ...pendingPreview, lidar: 'Лидар: нет связи с БК', camera: 'Камера: нет связи с БК' });
|
||||||
|
if (!native || !enabled)
|
||||||
|
return;
|
||||||
|
let active = true, failed = false, peerID: string | undefined;
|
||||||
|
let keepalive: ReturnType<typeof setInterval> | undefined, follow: ReturnType<typeof setInterval> | undefined, iceTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let status = { ...pendingPreview };
|
||||||
|
const update = (patch: Partial<PreviewStatus>) => { if (active) {
|
||||||
|
const next = { ...status, ...patch };
|
||||||
|
if (JSON.stringify(next) !== JSON.stringify(status)) {
|
||||||
|
status = next;
|
||||||
|
onStatus(status);
|
||||||
|
}
|
||||||
|
} };
|
||||||
|
const viewer = native, freshness = previewFreshness();
|
||||||
const pc = new RTCPeerConnection({ iceServers: [] });
|
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||||
const rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
|
const rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
|
||||||
rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer';
|
rrd.binaryType = 'arraybuffer';
|
||||||
const fail=()=>{if(!active||failed)return;failed=true;update({lidar:'Лидар: восстанавливаем просмотр',camera:'Камера: ожидаем соединение',retry:true});clearInterval(follow);clearInterval(keepalive);pc.close();};
|
camera.binaryType = 'arraybuffer';
|
||||||
const cameraFail=()=>{update({camera:'Камера: изображение недоступно'});camera.close();};
|
const previousRecording = viewer.get_active_recording_id();
|
||||||
const decoder=previewCamera(video.current!,()=>update({camera:'Камера: изображение поступает'}),cameraFail);
|
const channel = viewer.open_channel('live-acquisition:' + device.snapshot.context.session_id + ':' + device.control?.acquisition_id + ':' + generation);
|
||||||
|
const fail = () => { if (!active || failed)
|
||||||
|
return; failed = true; update({ lidar: 'Лидар: восстанавливаем связь', camera: 'Камера: ожидаем соединение', retry: true, presented: false, cameraPresented: false }); clearInterval(follow); clearInterval(keepalive); pc.close(); };
|
||||||
|
let cameraDecoded = false, cameraFailed = false, lastVideoTime = -1, lastVideoProgress = 0, seenLidar = false;
|
||||||
|
const cameraFail = () => { cameraFailed = true; update({ camera: 'Камера: изображение недоступно', cameraPresented: false }); camera.close(); };
|
||||||
|
const decoder = previewCamera(video.current!, () => { cameraDecoded = true; lastVideoProgress = performance.now(); }, cameraFail);
|
||||||
const cameraFrames = previewFrames(payload => decoder.push(payload));
|
const cameraFrames = previewFrames(payload => decoder.push(payload));
|
||||||
let rrdFrames:ReturnType<typeof previewFrames>|undefined;
|
const rrdFrames = previewFrames(payload => { assertRrd(payload); if (!channel.ready)
|
||||||
rrd.onclose=()=>{if(active&&!failed)fail();};
|
throw new Error('Viewer unavailable'); channel.send_rrd(payload); });
|
||||||
camera.onclose=()=>{if(active&&!failed)update({camera:'Камера: изображение недоступно'});};
|
rrd.onclose = () => { if (active && !failed)
|
||||||
|
fail(); };
|
||||||
|
camera.onclose = () => { if (active && !failed)
|
||||||
|
cameraFail(); };
|
||||||
|
rrd.onmessage = event => {
|
||||||
|
if (!active)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
if (typeof event.data === 'string')
|
||||||
|
freshness.receive(JSON.parse(event.data));
|
||||||
|
else if (event.data instanceof ArrayBuffer)
|
||||||
|
rrdFrames.push(event.data);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
};
|
||||||
camera.onmessage = event => {
|
camera.onmessage = event => {
|
||||||
if(!active)return;
|
if (!active || failed)
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
if (typeof event.data === 'string') {
|
if (typeof event.data === 'string') {
|
||||||
const metadata = JSON.parse(event.data);
|
const metadata = JSON.parse(event.data);
|
||||||
if(metadata.type!=='camera-ready'||typeof metadata.mime!=='string')throw new Error('Invalid camera metadata');
|
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
|
||||||
|
throw new Error('Invalid camera metadata');
|
||||||
decoder.open(metadata.mime);
|
decoder.open(metadata.mime);
|
||||||
}else if(event.data instanceof ArrayBuffer)cameraFrames.push(event.data);
|
}
|
||||||
}catch{cameraFail();}
|
else if (event.data instanceof ArrayBuffer)
|
||||||
|
cameraFrames.push(event.data);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
cameraFail();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const {viewer,mount}=await host.ready;if(!active)return;
|
let synchronizedRecording: string | null = null;
|
||||||
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false});if(!active)return;
|
|
||||||
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
|
||||||
const channel=viewer.open_channel('live-acquisition:'+device.snapshot.context.session_id);viewClose=()=>channel.close();
|
|
||||||
rrdFrames=previewFrames(payload=>{assertRrd(payload);if(!channel.ready)throw new Error('Viewer unavailable');channel.send_rrd(payload);});
|
|
||||||
rrd.onmessage=event=>{if(active&&event.data instanceof ArrayBuffer)try{rrdFrames!.push(event.data);}catch{fail();}};
|
|
||||||
follow = setInterval(() => {
|
follow = setInterval(() => {
|
||||||
if(!active||failed)return;
|
if (!active || failed)
|
||||||
try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){
|
return;
|
||||||
if(status.lidar!=='Лидар: данные поступают')update({lidar:'Лидар: данные поступают'});
|
let presented = false;
|
||||||
viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);
|
try {
|
||||||
}}catch{/* Native recording has not opened yet. */}
|
const id = viewer.get_active_recording_id();
|
||||||
|
const range = id ? viewer.get_time_range(id, 'stream_time') : null;
|
||||||
|
if (id && range) {
|
||||||
|
if (synchronizedRecording !== id) {
|
||||||
|
viewer.set_active_timeline(id, 'stream_time');
|
||||||
|
if (viewer.get_active_timeline(id) === 'stream_time')
|
||||||
|
synchronizedRecording = id;
|
||||||
|
}
|
||||||
|
// Following is owned by the live blueprint. SetTime/SetPlaying would
|
||||||
|
// pin the cursor and override that profile on every heartbeat.
|
||||||
|
presented = id !== previousRecording && freshness.fresh && range.max > range.min;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* Native recording/timeline creation is asynchronous. */ }
|
||||||
|
seenLidar ||= presented;
|
||||||
|
const v = video.current!;
|
||||||
|
if (cameraDecoded && v.currentTime !== lastVideoTime) {
|
||||||
|
lastVideoTime = v.currentTime;
|
||||||
|
lastVideoProgress = performance.now();
|
||||||
|
}
|
||||||
|
const cameraPresented = cameraDecoded && !cameraFailed && performance.now() - lastVideoProgress < 2000;
|
||||||
|
update({ presented, cameraPresented, points: presented ? freshness.points : 0,
|
||||||
|
lidar: presented ? 'Лидар: данные поступают' : seenLidar ? 'Лидар: нет свежих данных' : 'Лидар: ожидаем данные',
|
||||||
|
camera: cameraFailed ? 'Камера: изображение недоступно' : cameraPresented ? 'Камера: изображение поступает' : cameraDecoded ? 'Камера: нет свежих кадров' : 'Камера: ожидаем изображение' });
|
||||||
}, 250);
|
}, 250);
|
||||||
await pc.setLocalDescription(await pc.createOffer());
|
await pc.setLocalDescription(await pc.createOffer());
|
||||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{
|
if (pc.iceGatheringState !== 'complete')
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
iceTimeout = setTimeout(() => reject(new Error('ICE timeout')), 8000);
|
iceTimeout = setTimeout(() => reject(new Error('ICE timeout')), 8000);
|
||||||
pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(iceTimeout);resolve();}};
|
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') {
|
||||||
|
clearTimeout(iceTimeout);
|
||||||
|
resolve();
|
||||||
|
} };
|
||||||
});
|
});
|
||||||
if(!active)return;
|
if (!active)
|
||||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer';media_protocol:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)});
|
return;
|
||||||
|
const answer = await perform<{
|
||||||
|
peer_id: string;
|
||||||
|
sdp: string;
|
||||||
|
type: 'answer';
|
||||||
|
media_protocol: string;
|
||||||
|
}>(transport, device, 'offer', { sdp: privateCandidate(pc.localDescription!.sdp), acquisition_id: device.control?.acquisition_id });
|
||||||
peerID = answer.peer_id;
|
peerID = answer.peer_id;
|
||||||
if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});return;}
|
if (!active) {
|
||||||
if(answer.media_protocol!==MEDIA_PROTOCOL){failed=true;clearInterval(follow);pc.close();update({lidar:'Для просмотра требуется обновление приложения на БК',camera:'Камера: ожидаем обновление',retry:false});return;}
|
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||||
pc.onconnectionstatechange=()=>{if(active&&['failed','closed'].includes(pc.connectionState))fail();};
|
return;
|
||||||
|
}
|
||||||
|
if (answer.media_protocol !== MEDIA_PROTOCOL) {
|
||||||
|
failed = true;
|
||||||
|
clearInterval(follow);
|
||||||
|
pc.close();
|
||||||
|
update({ lidar: 'Для просмотра требуется обновление приложения на БК', camera: 'Камера: ожидаем обновление', retry: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pc.onconnectionstatechange = () => { if (active && ['failed', 'closed'].includes(pc.connectionState))
|
||||||
|
fail(); };
|
||||||
await pc.setRemoteDescription({ type: answer.type, sdp: answer.sdp });
|
await pc.setRemoteDescription({ type: answer.type, sdp: answer.sdp });
|
||||||
keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000);
|
keepalive = setInterval(() => { for (const c of [rrd, camera])
|
||||||
|
if (c.readyState === 'open')
|
||||||
|
c.send('keepalive'); }, 5000);
|
||||||
};
|
};
|
||||||
void run().catch(fail);
|
void run().catch(fail);
|
||||||
return () => {
|
return () => {
|
||||||
active=false;clearInterval(keepalive);clearInterval(follow);clearTimeout(iceTimeout);
|
active = false;
|
||||||
rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.onicegatheringstatechange=null;pc.close();
|
clearInterval(keepalive);
|
||||||
rrdFrames?.close();cameraFrames.close();decoder.close();try{viewClose?.();}finally{host.dispose();}
|
clearInterval(follow);
|
||||||
if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});
|
clearTimeout(iceTimeout);
|
||||||
|
rrd.onmessage = null;
|
||||||
|
camera.onmessage = null;
|
||||||
|
camera.onclose = null;
|
||||||
|
rrd.onclose = null;
|
||||||
|
pc.onconnectionstatechange = null;
|
||||||
|
pc.onicegatheringstatechange = null;
|
||||||
|
pc.close();
|
||||||
|
rrdFrames.close();
|
||||||
|
cameraFrames.close();
|
||||||
|
decoder.close();
|
||||||
|
channel.close();
|
||||||
|
if (peerID)
|
||||||
|
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||||
};
|
};
|
||||||
},[device.snapshot.context.session_id,transport,createRerunHost,onStatus,spatial,video,generation]);
|
}, [native, device.snapshot.context.session_id, device.control?.acquisition_id, transport, onStatus, video, generation, enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { defaultSceneSettings, type SceneSettings } from '@mission-core/sensor-sdk';
|
||||||
|
import { perform, type Sensor, type SensorTransport } from './runtime';
|
||||||
|
const fields = { pointSize: 'point_size', accumulationSeconds: 'accumulation_seconds', colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color', showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid' } as const;
|
||||||
|
export function k1SceneSettings(value: Record<string, unknown> = {}): SceneSettings {
|
||||||
|
const settings = { ...defaultSceneSettings, customColor: '#f7f8f4' };
|
||||||
|
for (const [key, field] of Object.entries(fields))
|
||||||
|
if (value[field] !== undefined)
|
||||||
|
Object.assign(settings, { [key]: value[field] });
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
export function k1ScenePatch(settings: SceneSettings) {
|
||||||
|
return Object.fromEntries(Object.entries(fields).map(([key, field]) => [field, settings[key as keyof typeof fields]]));
|
||||||
|
}
|
||||||
|
/** One serialized autosave owner. Scene changes never issue physical commands. */
|
||||||
|
export function useK1SceneSettings(device: Sensor, transport: SensorTransport, failure: (error: unknown) => void) {
|
||||||
|
const [draft, setDraft] = useState(() => k1SceneSettings(device.live_settings));
|
||||||
|
const current = useRef(draft), dirty = useRef(false), running = useRef(false), queued = useRef(false), active = useRef(true);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
useEffect(() => { active.current = true; return () => { active.current = false; queued.current = false; }; }, []);
|
||||||
|
const stage = (patch: Partial<SceneSettings>) => { current.current = { ...current.current, ...patch }; dirty.current = true; setDraft(current.current); };
|
||||||
|
const flush = async () => {
|
||||||
|
if (!dirty.current)
|
||||||
|
return;
|
||||||
|
queued.current = true;
|
||||||
|
if (running.current)
|
||||||
|
return;
|
||||||
|
running.current = true;
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
while (queued.current && active.current) {
|
||||||
|
queued.current = false;
|
||||||
|
dirty.current = false;
|
||||||
|
const settings = k1ScenePatch(current.current);
|
||||||
|
await perform(transport, device, 'option', { profile: 'live-acquisition', settings });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
dirty.current = true;
|
||||||
|
queued.current = false;
|
||||||
|
if (active.current)
|
||||||
|
failure(error);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
running.current = false;
|
||||||
|
if (active.current)
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { draft, pending, stage, flush: () => void flush(), commit: (patch: Partial<SceneSettings>) => { stage(patch); void flush(); } };
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
|||||||
from debian import package # noqa: E402
|
from debian import package # noqa: E402
|
||||||
from runtime_payload import files as runtime_files # noqa: E402
|
from runtime_payload import files as runtime_files # noqa: E402
|
||||||
|
|
||||||
VERSION = "0.1.8"
|
VERSION = "0.1.9"
|
||||||
RESOURCES = (
|
RESOURCES = (
|
||||||
"plugins/xgrids-k1/profile_loader.py",
|
"plugins/xgrids-k1/profile_loader.py",
|
||||||
"plugins/xgrids-k1/plugin.manifest.json",
|
"plugins/xgrids-k1/plugin.manifest.json",
|
||||||
@@ -135,7 +135,7 @@ Architecture: amd64
|
|||||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||||
Section: admin
|
Section: admin
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Depends: mission-core-node (>= 0.8.0), mission-core-node (<< 0.9.0),
|
Depends: mission-core-node (>= 0.8.9), mission-core-node (<< 0.9.0),
|
||||||
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
||||||
Breaks: mission-core-node (<< 0.8.0)
|
Breaks: mission-core-node (<< 0.8.0)
|
||||||
Replaces: mission-core-node (<< 0.8.0)
|
Replaces: mission-core-node (<< 0.8.0)
|
||||||
|
|||||||
@@ -151,9 +151,21 @@ class NodeK1Sensor:
|
|||||||
await self.peers.close(params.get("peer_id"))
|
await self.peers.close(params.get("peer_id"))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
if action == "offer":
|
if action == "offer":
|
||||||
if item["snapshot"]["acquisition"] != "streaming":
|
acquisition_id = item["control"]["acquisition_id"]
|
||||||
raise ValueError("Acquisition is not active")
|
if (item["snapshot"]["acquisition"] != "streaming" or not acquisition_id
|
||||||
return await self.peers.offer(params)
|
or params.get("acquisition_id") != acquisition_id):
|
||||||
|
raise ValueError("Acquisition is not active or changed")
|
||||||
|
answer = await self.peers.offer(params)
|
||||||
|
# Signalling may outlive STOP or a replacement acquisition. Retire
|
||||||
|
# that disposable preview instead of attaching it to another run.
|
||||||
|
current = project_sensor(await self.raw_state(), node_id)
|
||||||
|
if (current is None or current["snapshot"]["acquisition"] != "streaming"
|
||||||
|
or current["snapshot"]["context"]["session_id"]
|
||||||
|
!= command["session"]["session_id"]
|
||||||
|
or current["control"]["acquisition_id"] != acquisition_id):
|
||||||
|
await self.peers.close(answer["peer_id"])
|
||||||
|
raise ValueError("Acquisition changed during preview signalling")
|
||||||
|
return answer
|
||||||
async with self.bridge.lock:
|
async with self.bridge.lock:
|
||||||
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
|
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
|
||||||
raise ValueError("Command expired before dispatch")
|
raise ValueError("Command expired before dispatch")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from uuid import uuid4
|
|||||||
import aioice.ice
|
import aioice.ice
|
||||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
||||||
|
|
||||||
MEDIA_PROTOCOL = "missioncore.node-preview/v1"
|
MEDIA_PROTOCOL = "missioncore.node-preview/v2"
|
||||||
MAX_PAYLOAD = 8 * 1024 * 1024
|
MAX_PAYLOAD = 8 * 1024 * 1024
|
||||||
FRAGMENT_BYTES = 16384
|
FRAGMENT_BYTES = 16384
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -175,6 +175,10 @@ class NodeMediaPeers:
|
|||||||
break
|
break
|
||||||
if payload:
|
if payload:
|
||||||
await self.send(channel, payload)
|
await self.send(channel, payload)
|
||||||
|
if subscriber:
|
||||||
|
# Ordered after native bytes. Age is source arrival age,
|
||||||
|
# not time spent replaying an encoded preview backlog.
|
||||||
|
channel.send(json.dumps(subscriber.snapshot()))
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ Latest-value queues discard decoded preview frames before encoding; encoded
|
|||||||
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
|
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from k1link.data_plane import DecodedPointCloudView
|
||||||
from k1link.viewer.rerun_bridge import RerunBridge
|
from k1link.viewer.rerun_bridge import RerunBridge
|
||||||
|
|
||||||
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
|
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
|
||||||
|
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RrdSubscriber:
|
class RrdSubscriber:
|
||||||
@@ -21,6 +26,7 @@ class RrdSubscriber:
|
|||||||
self.inputs = queue.Queue(maxsize=2)
|
self.inputs = queue.Queue(maxsize=2)
|
||||||
self.output = queue.Queue(maxsize=2)
|
self.output = queue.Queue(maxsize=2)
|
||||||
self.settings_provider = settings_provider
|
self.settings_provider = settings_provider
|
||||||
|
self.last_frame = None
|
||||||
self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True)
|
self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
|
||||||
@@ -41,6 +47,12 @@ class RrdSubscriber:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
frame = self.last_frame
|
||||||
|
return {"type": "lidar-state", "sequence": frame[0] if frame else 0,
|
||||||
|
"age_ms": max(0, (time.monotonic_ns() - frame[1]) / 1_000_000) if frame else None,
|
||||||
|
"points": frame[2] if frame else 0}
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.closed.set()
|
self.closed.set()
|
||||||
|
|
||||||
@@ -58,7 +70,9 @@ class RrdSubscriber:
|
|||||||
bridge.begin_session()
|
bridge.begin_session()
|
||||||
while not self.closed.is_set():
|
while not self.closed.is_set():
|
||||||
payload = binary.read()
|
payload = binary.read()
|
||||||
if len(payload) > MAX_ENCODED_CHUNK:
|
# The SDK returns None while its live sink has no new bytes.
|
||||||
|
# An idle read is not EOF and must not retire the media peer.
|
||||||
|
if payload and len(payload) > MAX_ENCODED_CHUNK:
|
||||||
break
|
break
|
||||||
if payload:
|
if payload:
|
||||||
# Bound both bytes and waiting time. The archive/producer
|
# Bound both bytes and waiting time. The archive/producer
|
||||||
@@ -68,9 +82,20 @@ class RrdSubscriber:
|
|||||||
envelope = self.inputs.get(timeout=0.1)
|
envelope = self.inputs.get(timeout=0.1)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
received = envelope.context.received_monotonic_ns
|
||||||
|
if (received is not None
|
||||||
|
and (time.monotonic_ns() - received) / 1_000_000_000
|
||||||
|
> LIVE_FRAME_MAX_AGE_SECONDS):
|
||||||
|
continue
|
||||||
bridge.process(envelope)
|
bridge.process(envelope)
|
||||||
except Exception:
|
if isinstance(envelope, DecodedPointCloudView):
|
||||||
pass
|
self.last_frame = (
|
||||||
|
envelope.context.sequence,
|
||||||
|
envelope.context.received_monotonic_ns or time.monotonic_ns(),
|
||||||
|
envelope.point_count,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning("Node RRD subscriber failed exception=%s", type(error).__name__)
|
||||||
finally:
|
finally:
|
||||||
self.closed.set()
|
self.closed.set()
|
||||||
if bridge is not None:
|
if bridge is not None:
|
||||||
@@ -98,7 +123,7 @@ class NodeRerunBridge(RerunBridge):
|
|||||||
# It is continuously drained even when no viewer is attached.
|
# It is continuously drained even when no viewer is attached.
|
||||||
self.binary.read()
|
self.binary.read()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.latest[type(envelope)] = envelope
|
self.latest[type(envelope)] = (time.monotonic(), envelope)
|
||||||
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
||||||
for subscriber in self.subscribers:
|
for subscriber in self.subscribers:
|
||||||
subscriber.offer(envelope)
|
subscriber.offer(envelope)
|
||||||
@@ -113,7 +138,8 @@ class NodeRerunBridge(RerunBridge):
|
|||||||
if self._closed or len(self.subscribers) >= 2:
|
if self._closed or len(self.subscribers) >= 2:
|
||||||
raise RuntimeError("Live viewer unavailable")
|
raise RuntimeError("Live viewer unavailable")
|
||||||
subscriber = RrdSubscriber(self._settings_provider)
|
subscriber = RrdSubscriber(self._settings_provider)
|
||||||
for envelope in self.latest.values():
|
for observed, envelope in self.latest.values():
|
||||||
|
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
|
||||||
subscriber.offer(envelope)
|
subscriber.offer(envelope)
|
||||||
self.subscribers.append(subscriber)
|
self.subscribers.append(subscriber)
|
||||||
return subscriber
|
return subscriber
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
|
|||||||
position += 60 + length + length % 2
|
position += 60 + length + length % 2
|
||||||
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
||||||
control = archive.extractfile("control").read().decode()
|
control = archive.extractfile("control").read().decode()
|
||||||
assert "Depends: mission-core-node (>= 0.8.0)" in control
|
assert "Depends: mission-core-node (>= 0.8.9)" in control
|
||||||
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -556,3 +556,41 @@ def test_native_node_rrd_opens_no_grpc_listener(monkeypatch):
|
|||||||
subscriber.close()
|
subscriber.close()
|
||||||
subscriber.thread.join(6)
|
subscriber.thread.join(6)
|
||||||
publisher.close()
|
publisher.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("replacement", [False, True])
|
||||||
|
def test_preview_offer_cannot_cross_acquisition_boundary(replacement):
|
||||||
|
async def run():
|
||||||
|
device = bridge()
|
||||||
|
device.facade.current["acquisition"] = {"acquisition_id": "acq-one", "state": "running"}
|
||||||
|
device.facade.current["source_mode"] = "live"
|
||||||
|
|
||||||
|
class Peers:
|
||||||
|
opened = 0
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
async def offer(self, _):
|
||||||
|
self.opened += 1
|
||||||
|
if replacement:
|
||||||
|
device.facade.current["acquisition"]["acquisition_id"] = "acq-two"
|
||||||
|
return {"peer_id": "synthetic-peer"}
|
||||||
|
|
||||||
|
async def close(self, identifier):
|
||||||
|
self.closed.append(identifier)
|
||||||
|
|
||||||
|
peers = Peers()
|
||||||
|
sensor = NodeK1Sensor(device, peers)
|
||||||
|
item = project_sensor(device.facade.current, "node-test")
|
||||||
|
command = {
|
||||||
|
"operation_id": "op_" + "a" * 32,
|
||||||
|
"action_id": "offer",
|
||||||
|
"session": {"device_id": item["id"], "session_id": "session-test"},
|
||||||
|
"parameters": {"acquisition_id": "acq-one" if replacement else "acq-old"},
|
||||||
|
}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await sensor.execute(command, "node-test")
|
||||||
|
assert peers.opened == int(replacement)
|
||||||
|
assert peers.closed == (["synthetic-peer"] if replacement else [])
|
||||||
|
assert all(action == "state.read" for action, _ in device.facade.actions)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
return {"type": "lidar-state", "sequence": 1, "age_ms": 0, "points": 5000}
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -90,6 +93,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
|||||||
|
|
||||||
@channel.on("message")
|
@channel.on("message")
|
||||||
def message(data):
|
def message(data):
|
||||||
|
if isinstance(data, str):
|
||||||
|
return
|
||||||
payloads.append(data)
|
payloads.append(data)
|
||||||
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
|
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
|
||||||
received.set()
|
received.set()
|
||||||
@@ -153,3 +158,90 @@ def test_installed_camera_uses_declared_os_ffmpeg():
|
|||||||
unit = (root / "plugins/xgrids-k1/packaging/mission-core-k1.service").read_text()
|
unit = (root / "plugins/xgrids-k1/packaging/mission-core-k1.service").read_text()
|
||||||
assert "Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg" in unit
|
assert "Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg" in unit
|
||||||
assert "iproute2, ffmpeg" in (root / "plugins/xgrids-k1/packaging/build_deb.py").read_text()
|
assert "iproute2, ffmpeg" in (root / "plugins/xgrids-k1/packaging/build_deb.py").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
|
||||||
|
"""Regression: the real binary sink, two media channels, idle then resume."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from k1link.data_plane import ConsumerFrameContext, DecodedPointCloudView
|
||||||
|
from k1link.viewer.node_rerun import NodeRerunBridge
|
||||||
|
|
||||||
|
class Segments:
|
||||||
|
def __init__(self):
|
||||||
|
self.queue = queue.Queue()
|
||||||
|
|
||||||
|
def get(self, timeout):
|
||||||
|
return self.queue.get(timeout=timeout)
|
||||||
|
|
||||||
|
class Camera:
|
||||||
|
def __init__(self):
|
||||||
|
self.lease = SimpleNamespace(segments=Segments())
|
||||||
|
self.released = []
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
return {"generation": 1, "delivery": {"media_type": "video/mp4"}}
|
||||||
|
|
||||||
|
def open_delivery(self, generation):
|
||||||
|
assert generation == 1
|
||||||
|
return self.lease
|
||||||
|
|
||||||
|
def release_delivery(self, lease, **_):
|
||||||
|
self.released.append(lease)
|
||||||
|
|
||||||
|
def mark_streaming(self, _):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
import aioice.ice
|
||||||
|
monkeypatch.setattr(aioice.ice, "get_host_addresses", lambda **_: ["127.0.0.1"])
|
||||||
|
hub, camera = NodeRerunBridge(), Camera()
|
||||||
|
peers = NodeMediaPeers(hub, camera)
|
||||||
|
client = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||||
|
rrd = client.createDataChannel("rrd", ordered=True)
|
||||||
|
video = client.createDataChannel("camera", ordered=True)
|
||||||
|
ready, resumed, camera_frame = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
@rrd.on("message")
|
||||||
|
def rrd_message(data):
|
||||||
|
if isinstance(data, str):
|
||||||
|
value = json.loads(data)
|
||||||
|
if value["sequence"] == 1:
|
||||||
|
resumed.set()
|
||||||
|
else:
|
||||||
|
ready.set()
|
||||||
|
|
||||||
|
@video.on("message")
|
||||||
|
def camera_message(data):
|
||||||
|
if data == b"synthetic-camera-fragment":
|
||||||
|
camera_frame.set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.setLocalDescription(await client.createOffer())
|
||||||
|
answer = await peers.offer({"sdp": client.localDescription.sdp})
|
||||||
|
await client.setRemoteDescription(
|
||||||
|
RTCSessionDescription(sdp=answer["sdp"], type="answer")
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(ready.wait(), 8)
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
assert answer["peer_id"] in peers.items
|
||||||
|
assert rrd.readyState == video.readyState == "open"
|
||||||
|
assert not camera.released
|
||||||
|
now = time.monotonic_ns()
|
||||||
|
hub.process(DecodedPointCloudView(
|
||||||
|
ConsumerFrameContext(1, time.time_ns(), now, now, 1, True),
|
||||||
|
"world", ((1.0, 2.0, 3.0),), b"\x01",
|
||||||
|
))
|
||||||
|
camera.lease.segments.queue.put(("media", b"synthetic-camera-fragment"))
|
||||||
|
await asyncio.wait_for(resumed.wait(), 5)
|
||||||
|
await asyncio.wait_for(camera_frame.wait(), 5)
|
||||||
|
assert rrd.readyState == video.readyState == "open"
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
await peers.close_all()
|
||||||
|
hub.close()
|
||||||
|
assert not peers.items
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Native RRD empty reads are pauses, not peer EOF. No device/network involved."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from k1link.data_plane import ConsumerFrameContext, DecodedPointCloudView
|
||||||
|
from k1link.viewer.node_rerun import NodeRerunBridge, RrdSubscriber
|
||||||
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||||
|
|
||||||
|
|
||||||
|
def points(sequence=1, age=0):
|
||||||
|
now = time.monotonic_ns()
|
||||||
|
return DecodedPointCloudView(
|
||||||
|
context=ConsumerFrameContext(sequence, time.time_ns(), now - int(age * 1e9), now, 1, True),
|
||||||
|
frame_id="world", positions_xyz=((1.0, 2.0, 3.0),), intensities=b"\x01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def drain_until(subscriber, predicate, seconds=3):
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
payloads = []
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
payload = subscriber.read()
|
||||||
|
assert payload is not None, "idle native binary read retired the live subscriber"
|
||||||
|
if payload:
|
||||||
|
assert payload.startswith(b"RRF2")
|
||||||
|
payloads.append(payload)
|
||||||
|
if predicate():
|
||||||
|
return payloads
|
||||||
|
raise AssertionError("native subscriber did not make progress")
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_idle_read_keeps_subscriber_alive_and_resumes():
|
||||||
|
subscriber = RrdSubscriber(RerunSceneSettings)
|
||||||
|
try:
|
||||||
|
assert drain_until(subscriber, lambda: subscriber.output.empty())
|
||||||
|
# This forces several SDK reads with no data; the previous len(None)
|
||||||
|
# retired both RRD and camera before a first point could arrive.
|
||||||
|
assert subscriber.read() == b""
|
||||||
|
assert not subscriber.closed.is_set()
|
||||||
|
subscriber.offer(points())
|
||||||
|
assert drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 1)
|
||||||
|
assert subscriber.read() == b""
|
||||||
|
subscriber.offer(points(2))
|
||||||
|
assert drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 2)
|
||||||
|
assert subscriber.snapshot()["points"] == 1
|
||||||
|
assert not subscriber.closed.is_set()
|
||||||
|
finally:
|
||||||
|
subscriber.close()
|
||||||
|
subscriber.thread.join(timeout=3)
|
||||||
|
assert not subscriber.thread.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscriber_does_not_publish_queued_stale_points():
|
||||||
|
subscriber = RrdSubscriber(RerunSceneSettings)
|
||||||
|
try:
|
||||||
|
drain_until(subscriber, lambda: subscriber.output.empty())
|
||||||
|
subscriber.offer(points(age=5))
|
||||||
|
assert subscriber.read() == b""
|
||||||
|
assert subscriber.snapshot()["sequence"] == 0
|
||||||
|
subscriber.offer(points(2))
|
||||||
|
drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 2)
|
||||||
|
finally:
|
||||||
|
subscriber.close()
|
||||||
|
subscriber.thread.join(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopened_viewer_does_not_replay_cached_points_after_source_pause(monkeypatch):
|
||||||
|
bridge = NodeRerunBridge()
|
||||||
|
try:
|
||||||
|
bridge.process(points())
|
||||||
|
cached_at, frame = next(iter(bridge.latest.values()))
|
||||||
|
bridge.latest[type(frame)] = (cached_at - 5, frame)
|
||||||
|
sub = bridge.subscribe()
|
||||||
|
drain_until(sub, lambda: sub.output.empty())
|
||||||
|
assert sub.read() == b""
|
||||||
|
assert sub.snapshot()["sequence"] == 0
|
||||||
|
bridge.process(points(2))
|
||||||
|
drain_until(sub, lambda: sub.snapshot()["sequence"] == 2)
|
||||||
|
finally:
|
||||||
|
bridge.close()
|
||||||
|
sub.thread.join(timeout=3)
|
||||||
Reference in New Issue
Block a user