feat(observation): add dynamic camera sources

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 20:56:41 +03:00
parent 05bdab24a5
commit a281faf923
15 changed files with 1592 additions and 52 deletions
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
GlassSurface,
@@ -7,7 +7,19 @@ import {
} from "@nodedc/ui-react";
import { MetricCard } from "../components/MetricCard";
import type { BackendStatus, MissionRuntimeState } from "../core/runtime/contracts";
import {
ObservationMedia,
ObservationSourcePicker,
observationSourceStatusLabel,
} from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type {
BackendStatus,
MissionRuntimeState,
ObservationSourceDescriptor,
} from "../core/runtime/contracts";
import {
RerunViewport,
type RerunSelection,
@@ -84,6 +96,7 @@ export interface WorkspaceRendererProps {
backendStatus: BackendStatus;
sourceUrl: string;
sceneSettings: SceneSettings;
observationLayout: ObservationLayoutController;
navigation: WorkspaceNavigation;
}
@@ -236,6 +249,7 @@ function SpatialWorkspace({
state,
sourceUrl,
sceneSettings,
observationLayout,
navigation,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
@@ -246,6 +260,23 @@ function SpatialWorkspace({
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const observationSources = state?.observationSources ?? [];
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
const pointCloudVisible = pointCloudSource
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
: Boolean(sourceUrl.trim());
const mediaSources = observationSources.filter(
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
);
const visibleMediaSources = mediaSources.filter((source) =>
observationLayout.visibleSourceIds.has(source.id),
);
const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
);
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
@@ -253,6 +284,13 @@ function SpatialWorkspace({
}, []);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle");
setViewerMessage("");
setSelection(null);
}, [pointCloudVisible, sourceUrl]);
const viewerStatusLabel = {
idle: "Источник не назначен",
loading: "Подключение",
@@ -263,14 +301,14 @@ function SpatialWorkspace({
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
return (
<div className="spatial-workspace">
<div className="spatial-workspace" data-focused={pointCloudFocused ? "true" : undefined}>
<div className="spatial-toolbar">
<div className="spatial-toolbar__mode">
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
</div>
<div className="spatial-toolbar__actions">
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
Источник
Движок
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
Слои
@@ -281,8 +319,13 @@ function SpatialWorkspace({
</div>
</div>
<div className="spatial-viewport-shell">
{sourceUrl.trim() ? (
<div
ref={viewportRef}
className="spatial-viewport-shell"
data-primary-focused={pointCloudFocused ? "true" : undefined}
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
>
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
onStatusChange={onStatusChange}
@@ -292,13 +335,49 @@ function SpatialWorkspace({
<EmptySpatialStage settings={sceneSettings} />
)}
<div className="scene-status scene-status--top-left">
{pointCloudFocused ? (
<button
type="button"
className="scene-focus-exit"
aria-label="Выйти из полноэкранного режима облака точек"
onClick={() => observationLayout.setFocusedSourceId(null)}
>
<Icon name="minimize" size={16} />
</button>
) : !floatingSourceMaximized ? (
<div className="scene-source-controls">
<ObservationSourcePicker
sources={observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
<button
type="button"
className="scene-source-control"
aria-label="Развернуть облако точек"
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
>
<Icon name="expand" size={16} />
</button>
) : null}
</div>
) : null}
<div
className="scene-status scene-status--top-left"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
{viewerMessage ? <small>{viewerMessage}</small> : null}
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
<div
className="scene-metrics"
aria-label="Метрики пространственной сцены"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
@@ -313,7 +392,7 @@ function SpatialWorkspace({
</div>
</div>
{state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
@@ -323,7 +402,7 @@ function SpatialWorkspace({
</div>
) : null}
{selection ? (
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
@@ -331,21 +410,33 @@ function SpatialWorkspace({
</div>
) : null}
<div className="scene-timeline">
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
Воспроизвести
</Button>
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
</div>
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
ЭФИР
</span>
</div>
{!pointCloudFocused && !floatingSourceMaximized ? (
<ObservationTimeline
active={viewerStatus === "ready"}
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
mode={timeline?.mode}
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}
className="scene-timeline"
/>
) : null}
{!pointCloudFocused ? visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
index={index}
boundsRef={viewportRef}
rect={observationLayout.windowRects[source.id]}
maximized={observationLayout.maximizedFloatingSourceId === source.id}
active={observationLayout.activeFloatingSourceId === source.id}
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
onClose={() => observationLayout.hideSource(source.id)}
/>
)) : null}
</div>
<div className="spatial-contract-strip">
@@ -360,31 +451,85 @@ function SpatialWorkspace({
);
}
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
function CameraSourceCard({
source,
focused,
onFocus,
}: {
source: ObservationSourceDescriptor;
focused: boolean;
onFocus: () => void;
}) {
return (
<div className="standard-workspace cameras-workspace">
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
<div className="camera-grid">
{slots.map((slot, index) => (
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
<div className="camera-slot__grid" aria-hidden="true" />
<header>
<span>{slot}</span>
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
</header>
<div className="camera-slot__empty">
<Icon name={index === 3 ? "image" : "video"} />
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
</div>
<footer>
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
</footer>
</div>
))}
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
<header>
<span>{source.label}</span>
<div className="camera-slot__head-actions">
<span className="camera-slot__status">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
{source.capabilities.fullscreen ? (
<button
type="button"
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
onClick={onFocus}
>
<Icon name={focused ? "minimize" : "expand"} size={15} />
</button>
) : null}
</div>
</header>
<div className="camera-slot__body">
<ObservationMedia source={source} />
</div>
<FeatureInventory definition={definition} />
<footer>
<span>{source.description}</span>
<small>{source.endpointLabel || source.transport}</small>
</footer>
</article>
);
}
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
const sources = (state?.observationSources ?? []).filter(
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
);
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
const timeline = state?.observationTimeline;
const displayedSources = focusedSource ? [focusedSource] : sources;
return (
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
<div className="camera-workspace__catalog">
<span>{sources.length ? `${sources.length} визуальных каналов` : "Каталог источников пуст"}</span>
</div>
<div className="camera-grid" data-count={displayedSources.length}>
{displayedSources.length ? displayedSources.map((source) => (
<CameraSourceCard
key={source.id}
source={source}
focused={focusedSource?.id === source.id}
onFocus={() => observationLayout.setFocusedSourceId(
focusedSource?.id === source.id ? null : source.id,
)}
/>
)) : (
<div className="camera-grid__empty">
<Icon name="video" size={22} />
<strong>Камеры не объявлены</strong>
<span>Выберите модель устройства плагин опубликует доступные видеоканалы.</span>
</div>
)}
</div>
<ObservationTimeline
active={sources.some((source) => source.availability === "streaming")}
sourceCount={sources.length}
mode={timeline?.mode}
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}
className="camera-timeline"
/>
</div>
);
}