feat(observation): add dynamic camera sources
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user