feat(ui): refine live observation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 01:04:48 +03:00
parent 2bda1986bd
commit aa2df560b7
11 changed files with 319 additions and 143 deletions
+132 -108
View File
@@ -301,7 +301,10 @@ function SpatialWorkspace({
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
return (
<div className="spatial-workspace" data-focused={pointCloudFocused ? "true" : undefined}>
<div
className="spatial-workspace"
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
>
<div className="spatial-toolbar">
<div className="spatial-toolbar__mode">
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
@@ -325,120 +328,125 @@ function SpatialWorkspace({
data-primary-focused={pointCloudFocused ? "true" : undefined}
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
>
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
followLive={state?.sourceMode === "live"}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
{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}
pendingSourceIds={observationLayout.pendingSourceIds}
onToggle={observationLayout.toggleSource}
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
followLive={state?.sourceMode === "live"}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
{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}
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
<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>
{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}
pendingSourceIds={observationLayout.pendingSourceIds}
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-metrics"
aria-label="Метрики пространственной сцены"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
<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>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
</div>
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
<div
className="scene-metrics"
aria-label="Метрики пространственной сцены"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
) : null}
{!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 && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : 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={() => void observationLayout.hideSource(source.id)}
/>
)) : null}
{!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}
count={visibleMediaSources.length}
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={() => {
if (observationLayout.pendingSourceIds.has(source.id)) return;
observationLayout.setFloatingMaximized(source.id, false);
void observationLayout.hideSource(source.id);
}}
/>
)) : null}
</div>
<div className="spatial-contract-strip">
@@ -461,6 +469,7 @@ function CameraSourceCard({
selectedPeer,
onToggle,
onFocus,
onClose,
}: {
source: ObservationSourceDescriptor;
focused: boolean;
@@ -469,6 +478,7 @@ function CameraSourceCard({
selectedPeer: boolean;
onToggle: () => void;
onFocus: () => void;
onClose: () => void;
}) {
const deliveryActive = Boolean(
(source.delivery || source.previewUrl) &&
@@ -500,7 +510,7 @@ function CameraSourceCard({
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
{source.activation ? (
{source.activation && !focused ? (
<button
type="button"
className="camera-slot__activation"
@@ -510,7 +520,7 @@ function CameraSourceCard({
{activationLabel}
</button>
) : null}
{source.capabilities.fullscreen && deliveryActive ? (
{source.capabilities.fullscreen && (focused || deliveryActive) ? (
<button
type="button"
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
@@ -519,6 +529,16 @@ function CameraSourceCard({
<Icon name={focused ? "minimize" : "expand"} size={15} />
</button>
) : null}
{focused ? (
<button
type="button"
aria-label={`Закрыть ${source.label}`}
disabled={pending}
onClick={onClose}
>
<Icon name="close" size={15} />
</button>
) : null}
</div>
</header>
<div className="camera-slot__body">
@@ -563,6 +583,10 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
onFocus={() => observationLayout.setFocusedSourceId(
focusedSource?.id === source.id ? null : source.id,
)}
onClose={() => {
observationLayout.setFocusedSourceId(null);
void observationLayout.hideSource(source.id);
}}
/>
)) : (
<div className="camera-grid__empty">