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 {
|
||||
AdminNavigationPanel,
|
||||
@@ -5,15 +7,11 @@ import {
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
UserProfileMenu,
|
||||
@@ -66,8 +64,6 @@ import {
|
||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
@@ -77,22 +73,6 @@ import "./styles/scene-windows.css";
|
||||
type SceneToolWindowId = "sources" | "display" | "layers";
|
||||
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 {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
@@ -1029,132 +1009,8 @@ export default function App() {
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
>
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
@@ -1180,64 +1036,7 @@ export default function App() {
|
||||
</span>
|
||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["geometry"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "geometry",
|
||||
label: "Геометрия",
|
||||
description: "Облако точек и путь",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
|
||||
<Checker disabled={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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import type {RefObject} from "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 { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
@@ -10,60 +11,6 @@ import type {
|
||||
RecordedCameraAdmissionState,
|
||||
} 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({
|
||||
source,
|
||||
index,
|
||||
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => 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={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}`}
|
||||
>
|
||||
return <FloatingMediaWindow title={source.label} subtitle={source.description}
|
||||
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
|
||||
active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
|
||||
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>}>
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
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 {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
@@ -16,28 +16,6 @@ import type {
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
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({
|
||||
source,
|
||||
playback,
|
||||
@@ -159,105 +137,3 @@ export function ObservationMedia({
|
||||
</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";
|
||||
|
||||
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")}`;
|
||||
}
|
||||
export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
|
||||
|
||||
@@ -1,31 +1 @@
|
||||
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,
|
||||
};
|
||||
export * from "../../../packages/spatial-ui/src/sceneSettings";
|
||||
|
||||
@@ -1,659 +1 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/observation.css";
|
||||
|
||||
@@ -1,83 +1 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/scene-windows.css";
|
||||
|
||||
@@ -1,484 +1 @@
|
||||
.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; }
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/spatial.css";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
@@ -498,15 +499,7 @@ function SpatialWorkspace({
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
Движок
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||
Слои
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
||||
Отображение
|
||||
</Button>
|
||||
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/>
|
||||
</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 markup=render(active);
|
||||
assert.match(markup,/Остановить устройство/);
|
||||
assert.match(markup,/k1-preview-spatial/);
|
||||
assert.match(markup,/rerun-viewport__canvas/);
|
||||
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,/Инициировать запуск|Обновить просмотр/);
|
||||
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 {execFileSync} from 'node:child_process';
|
||||
import {createServer} from 'vite';
|
||||
let server,previewFrames,assertRrd;
|
||||
let server,previewFrames,assertRrd,previewFreshness,previewCamera;
|
||||
before(async()=>{
|
||||
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'));
|
||||
});
|
||||
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))));
|
||||
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 () => {
|
||||
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",
|
||||
);
|
||||
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 () => {
|
||||
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",
|
||||
);
|
||||
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 () => {
|
||||
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(
|
||||
css,
|
||||
/\.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_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_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()},
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.8.8"
|
||||
VERSION = "0.8.9"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
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);
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
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>
|
||||
{!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;
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './contracts';
|
||||
export type * from './enrollment';
|
||||
export type * from './rerunHost';
|
||||
export type * from './extensions';
|
||||
export * from '../../spatial-ui/src';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/** Native renderer capability supplied by each host, independent of device APIs. */
|
||||
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};
|
||||
override_panel_state:(panel:'top'|'blueprint'|'selection'|'time',state:'hidden'|'collapsed'|'expanded')=>void;
|
||||
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;
|
||||
set_active_timeline:(id:string,timeline:string)=>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 {K1LiveView} from './K1LiveView';
|
||||
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 {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}){
|
||||
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 scene=useK1SceneSettings(device,transport,failure);
|
||||
const running=useRef(false);
|
||||
const failedAction=useRef('');
|
||||
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">
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><Button onClick={back}>К устройствам</Button><StatusBadge tone={status.tone}>{status.label}</StatusBadge></div>
|
||||
<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'}
|
||||
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'}
|
||||
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>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Статус" align="center" description={streaming?`${summary} ${preview.lidar}. ${preview.camera}.`:summary} role="status" aria-live="polite"/>
|
||||
{manual.connected&&settings&&<K1LiveSettings device={device} transport={transport} enabled={enabled&&!pending} refresh={refresh} failure={failure}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview}/>}
|
||||
<SettingsCard title="Статус" role="status" aria-live="polite"><p className="k1-preview-status">{streaming?`${summary}. ${preview.lidar}. ${preview.camera}.`:summary}</p></SettingsCard>
|
||||
{manual.connected&&<K1SceneWindows tool={tool} close={()=>setTool(null)} scene={scene} connected={enabled}/>}
|
||||
{streaming&&createRerunHost&&<K1LiveView device={device} transport={transport} createRerunHost={createRerunHost} onStatus={setPreview} enabled={enabled} scene={scene} openTool={setTool}/>}
|
||||
</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 {Icon,IconButton,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
import type {Sensor,SensorTransport} from './runtime';
|
||||
import {useK1Preview,type PreviewStatus} from './useK1Preview';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Icon, IconButton, SettingsCard, StatusBadge } from '@nodedc/ui-react';
|
||||
import { FloatingMediaWindow, ObservationSourcePicker, ObservationTimeline, SpatialToolbarActions, type ObservationWindowRect, type RerunHostFactory, type SpatialSourceDescriptor } from '@mission-core/sensor-sdk';
|
||||
import type { Sensor, SensorTransport } from './runtime';
|
||||
import type { K1SceneState, SceneTool } from './K1SceneWindows';
|
||||
import { useK1Preview, pendingPreview, type PreviewStatus } from './useK1Preview';
|
||||
import './livePreview.css';
|
||||
|
||||
export function K1LiveView({device,transport,createRerunHost,onStatus}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory;onStatus:(value:PreviewStatus)=>void}){
|
||||
const spatial=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
|
||||
const [expanded,setExpanded]=useState(false);
|
||||
const [generation,setGeneration]=useState(0),[status,setStatus]=useState<PreviewStatus|null>(null);
|
||||
const attempts=useRef(0);
|
||||
const report=useCallback((value:PreviewStatus)=>{setStatus(value);onStatus(value);},[onStatus]);
|
||||
useK1Preview(device,transport,createRerunHost,spatial,video,report,generation);
|
||||
useEffect(()=>{
|
||||
if(!status?.retry)return;
|
||||
const timer=setTimeout(()=>setGeneration(value=>value+1),Math.min(30000,2000*2**Math.min(attempts.current++,4)));
|
||||
return()=>clearTimeout(timer);
|
||||
},[status?.retry]);
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]);
|
||||
return <SettingsCard title="Rerun" 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="k1-preview-spatial" ref={spatial}/>
|
||||
<video className="k1-preview-camera" hidden={status?.camera!=='Камера: изображение поступает'} ref={video} muted autoPlay playsInline aria-label="Камера K1"/>
|
||||
export function K1LiveView({ device, transport, createRerunHost, onStatus, enabled, scene, openTool }: {
|
||||
device: Sensor;
|
||||
transport: SensorTransport;
|
||||
createRerunHost: RerunHostFactory;
|
||||
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 report = useCallback((value: PreviewStatus) => { setStatus(value); onStatus(value); }, [onStatus]);
|
||||
useK1Preview(device, transport, createRerunHost, spatial, video, report, generation, enabled);
|
||||
useEffect(() => {
|
||||
if (!status.retry || !enabled)
|
||||
return;
|
||||
const timer = setTimeout(() => setGeneration(value => value + 1), Math.min(30000, 2000 * 2 ** Math.min(attempts.current++, 4)));
|
||||
return () => clearTimeout(timer);
|
||||
}, [status.retry, enabled]);
|
||||
useEffect(() => { if (!status.presented)
|
||||
return; const timer = setTimeout(() => { attempts.current = 0; }, 10000); return () => clearTimeout(timer); }, [status.presented]);
|
||||
useEffect(() => { const key = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -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-spatial {position:relative;min-width:0;width:100%;height:clamp(420px,60vh,800px);overflow:hidden}
|
||||
.k1-preview-spatial iframe {display:block;position:absolute;inset:0;width:100%;height:100%;border:0}
|
||||
.k1-preview-camera {display:block;width:100%;height:clamp(220px,32vh,360px);object-fit:contain}
|
||||
.k1-preview-camera[hidden] {display:none}
|
||||
.k1-preview.sensor-viewer-expanded .k1-preview-spatial {height:calc(100vh - 150px)}
|
||||
.k1-preview .spatial-workspace {height:clamp(540px,72vh,900px)}
|
||||
.k1-preview.sensor-viewer-expanded .spatial-workspace {height:calc(100vh - 150px)}
|
||||
.k1-camera-notice {position:absolute;inset:0}
|
||||
.k1-preview-status {margin:0;text-align:center;color:var(--nodedc-text-muted);font-size:var(--nodedc-font-size-xs)}
|
||||
|
||||
@@ -1,38 +1,71 @@
|
||||
/** One MSE decoder per preview; the onboard producer owns camera activation. */
|
||||
export function previewCamera(video:HTMLVideoElement,ready:()=>void,failed:()=>void){
|
||||
let active=true,media:MediaSource|undefined,buffer:SourceBuffer|undefined,url:string|undefined;
|
||||
let pending:Uint8Array<ArrayBuffer>[]=[],bytes=0;
|
||||
const append=()=>{
|
||||
if(!active||!buffer||buffer.updating||media?.readyState!=='open')return;
|
||||
try{
|
||||
if(buffer.buffered.length&&video.currentTime-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),video.currentTime-5);return;}
|
||||
const next=pending.shift();if(next){bytes-=next.byteLength;buffer.appendBuffer(next);}
|
||||
}catch{failed();}
|
||||
};
|
||||
const decoded=()=>{if(active)ready();};
|
||||
video.addEventListener('loadeddata',decoded);
|
||||
return {
|
||||
open(mime:string){
|
||||
if(media||typeof MediaSource==='undefined'||!MediaSource.isTypeSupported(mime))throw new Error('Camera format unavailable');
|
||||
media=new MediaSource();url=URL.createObjectURL(media);video.src=url;
|
||||
media.addEventListener('sourceopen',()=>{
|
||||
if(!active)return;
|
||||
try{
|
||||
buffer=media!.addSourceBuffer(mime);
|
||||
buffer.addEventListener('error',failed);
|
||||
buffer.addEventListener('updateend',()=>{
|
||||
if(!active)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();
|
||||
}catch{failed();}
|
||||
},{once:true});
|
||||
},
|
||||
push(payload:Uint8Array<ArrayBuffer>){
|
||||
if(!media)throw new Error('Camera metadata missing');
|
||||
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);},
|
||||
};
|
||||
export function previewCamera(video: HTMLVideoElement, ready: () => void, failed: () => void) {
|
||||
let active = true, media: MediaSource | undefined, buffer: SourceBuffer | undefined, url: string | undefined;
|
||||
const fail = () => { if (active) failed(); };
|
||||
let pending: Uint8Array<ArrayBuffer>[] = [], bytes = 0;
|
||||
const append = () => {
|
||||
if (!active || !buffer || buffer.updating || media?.readyState !== 'open')
|
||||
return;
|
||||
try {
|
||||
if (buffer.buffered.length && video.currentTime - buffer.buffered.start(0) > 8) {
|
||||
buffer.remove(buffer.buffered.start(0), video.currentTime - 5);
|
||||
return;
|
||||
}
|
||||
const next = pending.shift();
|
||||
if (next) {
|
||||
bytes -= next.byteLength;
|
||||
buffer.appendBuffer(next);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
fail();
|
||||
}
|
||||
};
|
||||
const decoded = () => { if (active)
|
||||
ready(); };
|
||||
video.addEventListener('loadeddata', decoded);
|
||||
return {
|
||||
open(mime: string) {
|
||||
if (media || typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported(mime))
|
||||
throw new Error('Camera format unavailable');
|
||||
media = new MediaSource();
|
||||
url = URL.createObjectURL(media);
|
||||
media.addEventListener('sourceopen', () => {
|
||||
if (!active)
|
||||
return;
|
||||
try {
|
||||
buffer = media!.addSourceBuffer(mime);
|
||||
buffer.addEventListener('error', fail);
|
||||
buffer.addEventListener('updateend', () => {
|
||||
if (!active)
|
||||
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();
|
||||
}
|
||||
catch {
|
||||
fail();
|
||||
}
|
||||
}, { once: true });
|
||||
// Installing the listener first also handles immediate sourceopen on reuse.
|
||||
video.src = url;
|
||||
},
|
||||
push(payload: Uint8Array<ArrayBuffer>) {
|
||||
if (!media)
|
||||
throw new Error('Camera metadata missing');
|
||||
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); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** 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;
|
||||
export function previewFrames(accept:(payload:Uint8Array<ArrayBuffer>)=>void) {
|
||||
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 type {RerunHostFactory} from '@mission-core/sensor-sdk';
|
||||
import {perform,type Sensor,type SensorTransport} from './runtime';
|
||||
import {assertRrd,MEDIA_PROTOCOL,previewFrames} from './previewFrames';
|
||||
import {previewCamera} from './previewCamera';
|
||||
|
||||
export type PreviewStatus={lidar:string;camera:string;retry:boolean};
|
||||
export const pendingPreview:PreviewStatus={lidar:'Лидар: ожидаем данные',camera:'Камера: ожидаем изображение',retry:false};
|
||||
|
||||
function privateCandidate(sdp:string):string{
|
||||
return sdp.split('\r\n').filter(line=>{
|
||||
if(!line.startsWith('a=candidate:'))return true;
|
||||
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)&&
|
||||
(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');
|
||||
import { useEffect, useState, type RefObject } from 'react';
|
||||
import type { RerunHostFactory, LiveRerunViewer } from '@mission-core/sensor-sdk';
|
||||
import { perform, type Sensor, type SensorTransport } from './runtime';
|
||||
import { assertRrd, MEDIA_PROTOCOL, previewFrames } from './previewFrames';
|
||||
import { previewCamera } from './previewCamera';
|
||||
import { previewFreshness } from './previewFreshness';
|
||||
export type PreviewStatus = {
|
||||
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 {
|
||||
return sdp.split('\r\n').filter(line => {
|
||||
if (!line.startsWith('a=candidate:'))
|
||||
return true;
|
||||
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) &&
|
||||
(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');
|
||||
}
|
||||
|
||||
export function useK1Preview(device:Sensor,transport:SensorTransport,createRerunHost:RerunHostFactory,
|
||||
spatial:RefObject<HTMLDivElement|null>,video:RefObject<HTMLVideoElement|null>,onStatus:(value:PreviewStatus)=>void,generation=0){
|
||||
useEffect(()=>{
|
||||
let active=true,failed=false,peerID:string|undefined,viewClose:(()=>void)|undefined;
|
||||
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 pc=new RTCPeerConnection({iceServers:[]});
|
||||
const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true});
|
||||
rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer';
|
||||
const fail=()=>{if(!active||failed)return;failed=true;update({lidar:'Лидар: восстанавливаем просмотр',camera:'Камера: ожидаем соединение',retry:true});clearInterval(follow);clearInterval(keepalive);pc.close();};
|
||||
const cameraFail=()=>{update({camera:'Камера: изображение недоступно'});camera.close();};
|
||||
const decoder=previewCamera(video.current!,()=>update({camera:'Камера: изображение поступает'}),cameraFail);
|
||||
const cameraFrames=previewFrames(payload=>decoder.push(payload));
|
||||
let rrdFrames:ReturnType<typeof previewFrames>|undefined;
|
||||
rrd.onclose=()=>{if(active&&!failed)fail();};
|
||||
camera.onclose=()=>{if(active&&!failed)update({camera:'Камера: изображение недоступно'});};
|
||||
camera.onmessage=event=>{
|
||||
if(!active)return;
|
||||
try{
|
||||
if(typeof event.data==='string'){
|
||||
const metadata=JSON.parse(event.data);
|
||||
if(metadata.type!=='camera-ready'||typeof metadata.mime!=='string')throw new Error('Invalid camera metadata');
|
||||
decoder.open(metadata.mime);
|
||||
}else if(event.data instanceof ArrayBuffer)cameraFrames.push(event.data);
|
||||
}catch{cameraFail();}
|
||||
};
|
||||
const run=async()=>{
|
||||
const {viewer,mount}=await host.ready;if(!active)return;
|
||||
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(()=>{
|
||||
if(!active||failed)return;
|
||||
try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){
|
||||
if(status.lidar!=='Лидар: данные поступают')update({lidar:'Лидар: данные поступают'});
|
||||
viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);
|
||||
}}catch{/* Native recording has not opened yet. */}
|
||||
},250);
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{
|
||||
iceTimeout=setTimeout(()=>reject(new Error('ICE timeout')),8000);
|
||||
pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(iceTimeout);resolve();}};
|
||||
});
|
||||
if(!active)return;
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer';media_protocol:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)});
|
||||
peerID=answer.peer_id;
|
||||
if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});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});
|
||||
keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000);
|
||||
};
|
||||
void run().catch(fail);
|
||||
return()=>{
|
||||
active=false;clearInterval(keepalive);clearInterval(follow);clearTimeout(iceTimeout);
|
||||
rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.onicegatheringstatechange=null;pc.close();
|
||||
rrdFrames?.close();cameraFrames.close();decoder.close();try{viewClose?.();}finally{host.dispose();}
|
||||
if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});
|
||||
};
|
||||
},[device.snapshot.context.session_id,transport,createRerunHost,onStatus,spatial,video,generation]);
|
||||
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) {
|
||||
const [native, setNative] = useState<LiveRerunViewer | null>(null);
|
||||
// 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(() => {
|
||||
let active = true;
|
||||
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 rrd = pc.createDataChannel('rrd', { ordered: true }), camera = pc.createDataChannel('camera', { ordered: true });
|
||||
rrd.binaryType = 'arraybuffer';
|
||||
camera.binaryType = 'arraybuffer';
|
||||
const previousRecording = viewer.get_active_recording_id();
|
||||
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 rrdFrames = previewFrames(payload => { assertRrd(payload); if (!channel.ready)
|
||||
throw new Error('Viewer unavailable'); channel.send_rrd(payload); });
|
||||
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 => {
|
||||
if (!active || failed)
|
||||
return;
|
||||
try {
|
||||
if (typeof event.data === 'string') {
|
||||
const metadata = JSON.parse(event.data);
|
||||
if (metadata.type !== 'camera-ready' || typeof metadata.mime !== 'string')
|
||||
throw new Error('Invalid camera metadata');
|
||||
decoder.open(metadata.mime);
|
||||
}
|
||||
else if (event.data instanceof ArrayBuffer)
|
||||
cameraFrames.push(event.data);
|
||||
}
|
||||
catch {
|
||||
cameraFail();
|
||||
}
|
||||
};
|
||||
const run = async () => {
|
||||
let synchronizedRecording: string | null = null;
|
||||
follow = setInterval(() => {
|
||||
if (!active || failed)
|
||||
return;
|
||||
let presented = false;
|
||||
try {
|
||||
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);
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if (pc.iceGatheringState !== 'complete')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
iceTimeout = setTimeout(() => reject(new Error('ICE timeout')), 8000);
|
||||
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
resolve();
|
||||
} };
|
||||
});
|
||||
if (!active)
|
||||
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;
|
||||
if (!active) {
|
||||
void perform(transport, device, 'close-peer', { peer_id: peerID }).catch(() => { });
|
||||
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 });
|
||||
keepalive = setInterval(() => { for (const c of [rrd, camera])
|
||||
if (c.readyState === 'open')
|
||||
c.send('keepalive'); }, 5000);
|
||||
};
|
||||
void run().catch(fail);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(keepalive);
|
||||
clearInterval(follow);
|
||||
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(() => { });
|
||||
};
|
||||
}, [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 runtime_payload import files as runtime_files # noqa: E402
|
||||
|
||||
VERSION = "0.1.8"
|
||||
VERSION = "0.1.9"
|
||||
RESOURCES = (
|
||||
"plugins/xgrids-k1/profile_loader.py",
|
||||
"plugins/xgrids-k1/plugin.manifest.json",
|
||||
@@ -135,7 +135,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
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
|
||||
Breaks: 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"))
|
||||
return {"ok": True}
|
||||
if action == "offer":
|
||||
if item["snapshot"]["acquisition"] != "streaming":
|
||||
raise ValueError("Acquisition is not active")
|
||||
return await self.peers.offer(params)
|
||||
acquisition_id = item["control"]["acquisition_id"]
|
||||
if (item["snapshot"]["acquisition"] != "streaming" or not acquisition_id
|
||||
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:
|
||||
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
|
||||
raise ValueError("Command expired before dispatch")
|
||||
|
||||
@@ -13,7 +13,7 @@ from uuid import uuid4
|
||||
import aioice.ice
|
||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
||||
|
||||
MEDIA_PROTOCOL = "missioncore.node-preview/v1"
|
||||
MEDIA_PROTOCOL = "missioncore.node-preview/v2"
|
||||
MAX_PAYLOAD = 8 * 1024 * 1024
|
||||
FRAGMENT_BYTES = 16384
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -175,6 +175,10 @@ class NodeMediaPeers:
|
||||
break
|
||||
if 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:
|
||||
pass
|
||||
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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView
|
||||
from k1link.viewer.rerun_bridge import RerunBridge
|
||||
|
||||
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
|
||||
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RrdSubscriber:
|
||||
@@ -21,6 +26,7 @@ class RrdSubscriber:
|
||||
self.inputs = queue.Queue(maxsize=2)
|
||||
self.output = queue.Queue(maxsize=2)
|
||||
self.settings_provider = settings_provider
|
||||
self.last_frame = None
|
||||
self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
@@ -41,6 +47,12 @@ class RrdSubscriber:
|
||||
except queue.Empty:
|
||||
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):
|
||||
self.closed.set()
|
||||
|
||||
@@ -58,7 +70,9 @@ class RrdSubscriber:
|
||||
bridge.begin_session()
|
||||
while not self.closed.is_set():
|
||||
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
|
||||
if payload:
|
||||
# Bound both bytes and waiting time. The archive/producer
|
||||
@@ -68,9 +82,20 @@ class RrdSubscriber:
|
||||
envelope = self.inputs.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(envelope, DecodedPointCloudView):
|
||||
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:
|
||||
self.closed.set()
|
||||
if bridge is not None:
|
||||
@@ -98,7 +123,7 @@ class NodeRerunBridge(RerunBridge):
|
||||
# It is continuously drained even when no viewer is attached.
|
||||
self.binary.read()
|
||||
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()]
|
||||
for subscriber in self.subscribers:
|
||||
subscriber.offer(envelope)
|
||||
@@ -113,8 +138,9 @@ class NodeRerunBridge(RerunBridge):
|
||||
if self._closed or len(self.subscribers) >= 2:
|
||||
raise RuntimeError("Live viewer unavailable")
|
||||
subscriber = RrdSubscriber(self._settings_provider)
|
||||
for envelope in self.latest.values():
|
||||
subscriber.offer(envelope)
|
||||
for observed, envelope in self.latest.values():
|
||||
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
|
||||
subscriber.offer(envelope)
|
||||
self.subscribers.append(subscriber)
|
||||
return subscriber
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
|
||||
position += 60 + length + length % 2
|
||||
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -556,3 +556,41 @@ def test_native_node_rrd_opens_no_grpc_listener(monkeypatch):
|
||||
subscriber.close()
|
||||
subscriber.thread.join(6)
|
||||
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:
|
||||
return b""
|
||||
|
||||
def snapshot(self):
|
||||
return {"type": "lidar-state", "sequence": 1, "age_ms": 0, "points": 5000}
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
@@ -90,6 +93,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
||||
|
||||
@channel.on("message")
|
||||
def message(data):
|
||||
if isinstance(data, str):
|
||||
return
|
||||
payloads.append(data)
|
||||
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
|
||||
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()
|
||||
assert "Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg" in unit
|
||||
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