feat(ui): refine live observation workspace
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user