feat(ui): separate live data and lab surfaces

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 12:50:10 +03:00
parent 677dbdb776
commit 05413e3f5c
19 changed files with 1430 additions and 402 deletions
+59 -41
View File
@@ -26,7 +26,6 @@ import {
} from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage";
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
@@ -160,7 +159,7 @@ export default function App() {
});
const [activeRoot, setActiveRoot] = useState<RootId | null>(
polygonDatasetRoute.active ? "polygon" : null,
polygonDatasetRoute.active ? "data" : null,
);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
@@ -199,24 +198,33 @@ export default function App() {
const currentRoot = rootById(activeRoot);
const visibleRoots = roots;
const activeDefinition = workspaceById(workspace.activeView);
const spatialWorkspaceActive = Boolean(
workspace.contentOpen && activeDefinition?.kind === "spatial",
const sceneWorkspaceActive = Boolean(
workspace.contentOpen
&& activeDefinition
&& ["spatial", "recordings"].includes(activeDefinition.kind),
);
const archiveWorkspaceActive = Boolean(
activeDefinition
&& ["recordings", "lab-archive"].includes(activeDefinition.kind),
);
const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl;
const effectiveSourceUrl = archiveWorkspaceActive
? replayTransitioning ? "" : sourceUrl
: automaticSourceUrl;
const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
const replayPresented = archiveWorkspaceActive && replayActive;
const recordedSessionAdmission = useRecordedSessionAdmission(
replayActive ? recordedReplay : null,
replayPresented ? recordedReplay : null,
);
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayActive;
replayActiveRef.current = replayPresented;
useEffect(() => {
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
polygonDatasetRouteOpenedRef.current = true;
workspace.openView("polygon-datasets");
workspace.openView("datasets");
}, [polygonDatasetRoute.active, workspace]);
if (!sceneSettingsCommitterRef.current) {
@@ -245,11 +253,11 @@ export default function App() {
() => recordedObservationSources(recordedReplay),
[recordedReplay],
);
const activeObservationSources = replayActive
const activeObservationSources = replayPresented
? replaySources
: runtime.state?.observationSources ?? [];
const activeRuntimeState = useMemo(() => {
if (!replayActive || !recordedReplay) return runtime.state;
if (!replayPresented || !recordedReplay) return runtime.state;
return {
...(runtime.state ?? { phase: "replaying" as const }),
phase: "replaying" as const,
@@ -272,7 +280,7 @@ export default function App() {
},
},
};
}, [recordedReplay, replayActive, replaySources, runtime.state]);
}, [recordedReplay, replayPresented, replaySources, runtime.state]);
const viewerSettingsTargetIdentity = [
runtime.state?.activeDevice?.pluginId,
runtime.state?.activeDevice?.modelId,
@@ -283,7 +291,7 @@ export default function App() {
].filter(Boolean).join(":") || "local-runtime";
const observationLayout = useObservationLayout(
activeObservationSources,
replayActive ? undefined : runtime.setObservationSourceActive,
replayPresented ? undefined : runtime.setObservationSourceActive,
);
const workspaceLayoutProfile = useWorkspaceLayoutProfile();
const focusedObservationSource = activeObservationSources.find(
@@ -335,14 +343,14 @@ export default function App() {
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
useEffect(() => {
if (spatialWorkspaceActive) return;
if (sceneWorkspaceActive) return;
// Scene tools are transient children of the spatial workspace. They must
// never survive a route/root/content close or appear over the landing page.
setSourceWindowOpen(false);
setDisplayWindowOpen(false);
setLayerInspectorOpen(false);
setSceneWindowOrder([]);
}, [spatialWorkspaceActive]);
}, [sceneWorkspaceActive]);
useEffect(() => {
sceneSettingsCommitterActiveRef.current = true;
@@ -398,7 +406,7 @@ export default function App() {
}, []);
useEffect(() => {
if (!spatialWorkspaceActive || !activeSceneWindow) return;
if (!sceneWorkspaceActive || !activeSceneWindow) return;
const closeActiveWindow = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
@@ -410,12 +418,12 @@ export default function App() {
document.addEventListener("keydown", closeActiveWindow);
return () => document.removeEventListener("keydown", closeActiveWindow);
}, [activeSceneWindow, closeSceneWindow, spatialWorkspaceActive]);
}, [activeSceneWindow, closeSceneWindow, sceneWorkspaceActive]);
const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId);
if (rootId === "polygon") {
workspace.openView("polygon-datasets");
workspace.openView("lab-archive");
workspace.openNavigation();
return;
}
@@ -431,7 +439,7 @@ export default function App() {
};
const openSource = () => {
if (!spatialWorkspaceActive) return;
if (!sceneWorkspaceActive) return;
setSourceDraft(sourceUrl);
setSourceWindowOpen(true);
activateSceneWindow("sources");
@@ -472,13 +480,13 @@ export default function App() {
}, [commitDisplaySettings]);
const openDisplay = () => {
if (!spatialWorkspaceActive) return;
if (!sceneWorkspaceActive) return;
setDisplayWindowOpen(true);
activateSceneWindow("display");
};
const openLayers = () => {
if (!spatialWorkspaceActive) return;
if (!sceneWorkspaceActive) return;
setLayerInspectorOpen(true);
activateSceneWindow("layers");
};
@@ -547,12 +555,9 @@ export default function App() {
}, []);
useEffect(() => {
// ObservationSessionSelect owns the cancellable request and unmounts when
// the operator leaves the spatial workspace. Its unmount cannot safely
// call back into this owner, so clear the transient blanking state here.
// Returning to Observation then starts from a deterministic idle source
// instead of an orphaned `replayTransitioning=true` state.
if (activeDefinition?.kind !== "spatial") setReplayTransitioning(false);
if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) {
setReplayTransitioning(false);
}
}, [activeDefinition?.kind]);
const applyScenePatch = (patch: Partial<SceneSettings>) => commitDisplayPatch(patch);
@@ -598,7 +603,10 @@ export default function App() {
});
}
if (activeDefinition?.kind === "spatial") {
if (
activeDefinition
&& ["spatial", "recordings"].includes(activeDefinition.kind)
) {
actions.push(
{
label: workspaceLayoutProfile.state === "saving"
@@ -709,7 +717,9 @@ export default function App() {
key={activeDefinition.id}
eyebrow={activeDefinition.eyebrow}
title={
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
activeDefinition.kind === "recordings"
&& replayPresented
&& recordedReplayLabel
? `${activeDefinition.title}: ${recordedReplayLabel}`
: activeDefinition.title
}
@@ -723,13 +733,9 @@ export default function App() {
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<div className="observation-header-tools">
<ObservationSessionSelect
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
/>
<StatusBadge tone={runtime.state?.sourceMode === "live" ? "success" : "neutral"}>
{runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"}
</StatusBadge>
{layoutSaveNotice || workspaceLayoutProfile.error ? (
<span
className="workspace-layout-feedback"
@@ -745,8 +751,12 @@ export default function App() {
</span>
) : null}
</div>
) : activeDefinition.kind === "polygon-datasets" ? (
) : activeDefinition.kind === "recordings" ? (
<StatusBadge tone="accent">Архив записей</StatusBadge>
) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
<StatusBadge tone="accent">Лаборатория</StatusBadge>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
@@ -765,7 +775,7 @@ export default function App() {
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
recordedReplay={replayActive ? recordedReplay : null}
recordedReplay={replayPresented ? recordedReplay : null}
recordedSessionAdmission={recordedSessionAdmission}
sceneSettings={sceneSettings}
accumulationSeconds={displayDraft.accumulationSeconds}
@@ -782,6 +792,14 @@ export default function App() {
model: selection.model,
}
: null}
sessionArchive={{
disabled: runtime.pendingAction !== null || sourceSwitchBlocked,
blockedReason: sourceSwitchBlockedReason,
onReplayBegin: beginRecordedReplaySwitch,
onReplayAccepted: acceptRecordedReplay,
onReplaySettled: (_session, outcome) =>
settleRecordedReplaySwitch(outcome),
}}
navigation={{
openView,
openSource,
@@ -796,7 +814,7 @@ export default function App() {
/>
<Window
open={spatialWorkspaceActive && sourceWindowOpen}
open={sceneWorkspaceActive && sourceWindowOpen}
title="Визуальный движок"
subtitle="Rerun gRPC и записи пространственной сцены"
placement="end"
@@ -924,7 +942,7 @@ export default function App() {
</Window>
<Window
open={spatialWorkspaceActive && displayWindowOpen}
open={sceneWorkspaceActive && displayWindowOpen}
title="Отображение"
subtitle="Параметры пространственной сцены"
placement="end"
@@ -1003,7 +1021,7 @@ export default function App() {
</ControlRow>
</div>
) : null}
{replayActive ? (
{replayPresented ? (
<p className="scene-window-note">
Первый новый режим читает индекс архивных точек. Следующие палитры
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
@@ -1068,7 +1086,7 @@ export default function App() {
</Window>
<Window
open={spatialWorkspaceActive && layerInspectorOpen}
open={sceneWorkspaceActive && layerInspectorOpen}
title="Слои сцены"
subtitle="Сущности пространственной сцены"
placement="end"