NODEDC_MISSION_CORE/apps/control-station/src/App.tsx

1126 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
Button,
Checker,
ColorField,
ControlRow,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
HeaderProfileButton,
HeaderWorkspace,
Icon,
Inspector,
RangeControl,
Select,
StatusBadge,
TextField,
Window,
WindowFooterActions,
useApplicationWorkspace,
type ApplicationPanelUtilityAction,
} 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";
import {
SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
isSpatialSourceSwitchBlocked,
} from "./core/runtime/acquisitionGuard";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
} from "./core/runtime/latestAsyncCommitter";
import { useObservationLayout } from "./core/observation/useObservationLayout";
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
import type {
ObservationSessionReplayLaunch,
ObservationSessionSummary,
} from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import {
OBSERVATION_WORKSPACE_ID,
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
type ObservationWorkspaceLayoutProfile,
} from "./core/observation/workspaceLayout";
import {
rootById,
roots,
workspaceById,
workspacesForRoot,
type RootId,
} from "./productModel";
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css";
type SceneToolWindowId = "sources" | "display" | "layers";
const viewerSettingsQuietPeriodMs = 750;
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
];
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
];
interface LivePerceptionLayers {
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
}
const defaultLivePerceptionLayers: LivePerceptionLayers = {
detections2d: false,
segmentation: false,
cuboids3d: false,
};
function toViewerSettings(
settings: SceneSettings,
perception: LivePerceptionLayers = defaultLivePerceptionLayers,
): ViewerSettings {
return {
point_size: settings.pointSize,
color_mode: settings.colorMode,
palette: settings.palette,
custom_color: settings.customColor,
accumulation_seconds: settings.accumulationSeconds,
show_points: settings.showPoints,
show_trajectory: settings.showTrajectory,
show_grid: settings.showGrid,
show_detections_2d: perception.detections2d,
show_segmentation: perception.segmentation,
show_cuboids_3d: perception.cuboids3d,
};
}
function mergeViewerSettings(
current: SceneSettings,
remote: ViewerSettings,
): SceneSettings {
const next = {
...current,
pointSize: remote.point_size,
colorMode: remote.color_mode,
palette: remote.palette,
customColor: remote.custom_color,
accumulationSeconds: remote.accumulation_seconds,
showPoints: remote.show_points,
showTrajectory: remote.show_trajectory,
showGrid: remote.show_grid,
};
const unchanged =
current.pointSize === next.pointSize &&
current.colorMode === next.colorMode &&
current.palette === next.palette &&
current.customColor === next.customColor &&
current.accumulationSeconds === next.accumulationSeconds &&
current.showPoints === next.showPoints &&
current.showTrajectory === next.showTrajectory &&
current.showGrid === next.showGrid;
return unchanged ? current : next;
}
export default function App() {
const runtime = useMissionRuntime();
const { selection } = useDevicePluginHost();
const workspace = useApplicationWorkspace<string>({
navigationOpen: false,
contentExpanded: true,
});
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false);
const [sourceDraft, setSourceDraft] = useState("");
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
const [livePerceptionLayers, setLivePerceptionLayers] = useState<LivePerceptionLayers>(
defaultLivePerceptionLayers,
);
const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
const sourceSwitchBlockedReason = sourceSwitchBlocked
? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
: null;
const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked);
sourceSwitchBlockedRef.current = sourceSwitchBlocked;
const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
const livePerceptionLayersRef = useRef<LivePerceptionLayers>(defaultLivePerceptionLayers);
const livePerceptionRevisionRef = useRef(0);
const replayActiveRef = useRef(false);
const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView);
const spatialWorkspaceActive = Boolean(
workspace.contentOpen && activeDefinition?.kind === "spatial",
);
const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl;
const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
const recordedSessionAdmission = useRecordedSessionAdmission(
replayActive ? recordedReplay : null,
);
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayActive;
if (!sceneSettingsCommitterRef.current) {
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
commit: (settings) => replayActiveRef.current
? Promise.resolve(true)
: runtimeUpdateViewerSettingsRef.current(
toViewerSettings(settings, livePerceptionLayersRef.current),
),
onSettled: ({ value, applied, superseded }) => {
if (!sceneSettingsCommitterActiveRef.current) return;
if (applied) confirmedSceneSettingsRef.current = value;
if (superseded) return;
const next = applied ? value : confirmedSceneSettingsRef.current;
sceneSettingsRef.current = next;
setSceneSettings(next);
if (!applied && displayDraftRef.current === value) {
displayDraftRef.current = next;
setDisplayDraft(next);
}
},
});
}
const replaySources = useMemo(
() => recordedObservationSources(recordedReplay),
[recordedReplay],
);
const activeObservationSources = replayActive
? replaySources
: runtime.state?.observationSources ?? [];
const activeRuntimeState = useMemo(() => {
if (!replayActive || !recordedReplay) return runtime.state;
return {
...(runtime.state ?? { phase: "replaying" as const }),
phase: "replaying" as const,
sourceMode: "replay" as const,
spatialSource: {
id: "recorded.spatial.primary",
url: recordedReplay.sourceUrl,
label: "Сохранённая пространственная сцена",
kind: "rrd" as const,
},
observationSources: replaySources,
observationTimeline: {
mode: "recorded" as const,
seekable: true,
sessionRecording: true,
synchronization: "host-arrival-best-effort" as const,
range: {
startSeconds: recordedReplay.timelineStartSeconds,
endSeconds: recordedReplay.timelineEndSeconds,
},
},
};
}, [recordedReplay, replayActive, replaySources, runtime.state]);
const viewerSettingsTargetIdentity = [
runtime.state?.activeDevice?.pluginId,
runtime.state?.activeDevice?.modelId,
runtime.state?.activeDevice?.instanceId,
runtime.state?.deviceSession?.sessionId,
runtime.state?.deviceSession?.deviceId,
runtime.state?.acquisition?.acquisitionId,
].filter(Boolean).join(":") || "local-runtime";
const observationLayout = useObservationLayout(
activeObservationSources,
replayActive ? undefined : runtime.setObservationSourceActive,
);
const workspaceLayoutProfile = useWorkspaceLayoutProfile();
const focusedObservationSource = activeObservationSources.find(
(source) => source.id === observationLayout.focusedSourceId,
);
const observationFullscreenActive = Boolean(
activeDefinition?.kind === "spatial"
? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud"
: activeDefinition?.kind === "cameras" && focusedObservationSource,
);
useEffect(() => {
const remote = runtime.state?.viewerSettings;
if (
!remote ||
workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy()
) return;
const merged = mergeViewerSettings(sceneSettingsRef.current, remote);
const remoteLayers = {
detections2d: remote.show_detections_2d ?? false,
segmentation: remote.show_segmentation ?? false,
cuboids3d: remote.show_cuboids_3d ?? false,
};
livePerceptionLayersRef.current = remoteLayers;
setLivePerceptionLayers(remoteLayers);
sceneSettingsRef.current = merged;
confirmedSceneSettingsRef.current = merged;
setSceneSettings(merged);
if (viewerSettingsCommitTimerRef.current === null) {
displayDraftRef.current = merged;
setDisplayDraft(merged);
}
}, [runtime.state?.viewerSettings, workspaceLayoutProfile.profile]);
useEffect(() => {
const profile = workspaceLayoutProfile.profile;
if (!profile) return;
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
}
sceneSettingsRef.current = profile.sceneSettings;
displayDraftRef.current = profile.sceneSettings;
confirmedSceneSettingsRef.current = profile.sceneSettings;
setSceneSettings(profile.sceneSettings);
setDisplayDraft(profile.sceneSettings);
observationLayout.restore(profile);
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
useEffect(() => {
if (spatialWorkspaceActive) 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]);
useEffect(() => {
sceneSettingsCommitterActiveRef.current = true;
return () => {
sceneSettingsCommitterActiveRef.current = false;
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
}
};
}, []);
useEffect(() => {
const profile = workspaceLayoutProfile.profile;
const applicationKey = profile
? `${profile.revision}:${viewerSettingsTargetIdentity}`
: null;
if (
!profile ||
runtime.backendStatus !== "online" ||
!applicationKey ||
appliedProfileKeyRef.current === applicationKey
) {
return;
}
appliedProfileKeyRef.current = applicationKey;
void runtime.updateViewerSettings(
toViewerSettings(profile.sceneSettings, livePerceptionLayersRef.current),
).then((applied) => {
if (!applied && appliedProfileKeyRef.current === applicationKey) {
appliedProfileKeyRef.current = null;
}
});
}, [
runtime.backendStatus,
runtime.updateViewerSettings,
viewerSettingsTargetIdentity,
workspaceLayoutProfile.profile,
]);
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
setSceneWindowOrder((current) => [
...current.filter((candidate) => candidate !== windowId),
windowId,
]);
}, []);
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false);
if (windowId === "layers") setLayerInspectorOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []);
useEffect(() => {
if (!spatialWorkspaceActive || !activeSceneWindow) return;
const closeActiveWindow = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
if (document.querySelector(".nodedc-dropdown-surface")) return;
if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return;
event.preventDefault();
closeSceneWindow(activeSceneWindow);
};
document.addEventListener("keydown", closeActiveWindow);
return () => document.removeEventListener("keydown", closeActiveWindow);
}, [activeSceneWindow, closeSceneWindow, spatialWorkspaceActive]);
const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId);
workspace.closeView();
workspace.openNavigation();
};
const openView = (viewId: string) => {
const definition = workspaceById(viewId);
if (!definition) return;
setActiveRoot(definition.root);
workspace.openView(viewId);
};
const openSource = () => {
if (!spatialWorkspaceActive) return;
setSourceDraft(sourceUrl);
setSourceWindowOpen(true);
activateSceneWindow("sources");
};
const commitDisplaySettings = useCallback((next: SceneSettings) => {
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
}
displayDraftRef.current = next;
setDisplayDraft(next);
sceneSettingsCommitterRef.current?.enqueue(next);
}, []);
const stageDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
const next = { ...displayDraftRef.current, ...patch };
displayDraftRef.current = next;
setDisplayDraft(next);
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
}
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, viewerSettingsQuietPeriodMs);
}, []);
const flushDisplaySettings = useCallback(() => {
if (viewerSettingsCommitTimerRef.current === null) return;
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, []);
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
}, [commitDisplaySettings]);
const openDisplay = () => {
if (!spatialWorkspaceActive) return;
setDisplayWindowOpen(true);
activateSceneWindow("display");
};
const openLayers = () => {
if (!spatialWorkspaceActive) return;
setLayerInspectorOpen(true);
activateSceneWindow("layers");
};
const changeLivePerceptionLayers = useCallback((next: LivePerceptionLayers) => {
const previous = livePerceptionLayersRef.current;
const revision = ++livePerceptionRevisionRef.current;
livePerceptionLayersRef.current = next;
setLivePerceptionLayers(next);
if (replayActiveRef.current) return;
void runtimeUpdateViewerSettingsRef.current(
toViewerSettings(sceneSettingsRef.current, next),
).then((applied) => {
if (applied || livePerceptionRevisionRef.current !== revision) return;
livePerceptionLayersRef.current = previous;
setLivePerceptionLayers(previous);
});
}, []);
const beginRecordedReplaySwitch = useCallback(async () => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
// useObservationSessions calls this only after backend preparation has
// produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before
// accepting the already-ready archive.
setReplayTransitioning(true);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
});
}, []);
const acceptRecordedReplay = useCallback((
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
setRecordedReplay(launch);
setRecordedReplayLabel(session.label);
setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl);
setReplayTransitioning(false);
}, []);
const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => {
if (outcome !== "accepted") setReplayTransitioning(false);
}, []);
const activateAutomaticSpatialSource = useCallback(() => {
// A plugin-owned live/file-replay start must release any host-selected
// archive or manual URL before the new acquisition becomes non-terminal.
// This transition is an internal start boundary, not an operator source
// switch, so it intentionally does not consult the acquisition guard.
setReplayTransitioning(false);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
}, []);
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);
}, [activeDefinition?.kind]);
const applyScenePatch = (patch: Partial<SceneSettings>) => commitDisplayPatch(patch);
const saveWorkspaceLayout = useCallback(async () => {
flushDisplaySettings();
await sceneSettingsCommitterRef.current?.waitForIdle();
const layout = observationLayout.snapshot();
if (!layout) {
setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
return;
}
const draft: ObservationWorkspaceLayoutProfile = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: workspaceLayoutProfile.profile?.revision ?? 0,
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: sceneSettingsRef.current,
...layout,
};
const saved = await workspaceLayoutProfile.save(draft);
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
}, [
flushDisplaySettings,
observationLayout.snapshot,
workspaceLayoutProfile,
]);
useEffect(() => {
if (!layoutSaveNotice || layoutSaveNotice !== "Компоновка сохранена") return;
const timer = window.setTimeout(() => setLayoutSaveNotice(null), 2600);
return () => window.clearTimeout(timer);
}, [layoutSaveNotice]);
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
const actions: ApplicationPanelUtilityAction[] = [];
if (activeDefinition?.kind === "device") {
actions.push({
label: "Обновить состояние локального контура",
icon: "refresh",
onClick: () => void runtime.refresh(),
});
}
if (activeDefinition?.kind === "spatial") {
actions.push(
{
label: workspaceLayoutProfile.state === "saving"
? "Сохраняем компоновку"
: "Сохранить компоновку",
icon: "save",
disabled: workspaceLayoutProfile.state === "saving",
onClick: () => void saveWorkspaceLayout(),
},
);
}
return actions;
}, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]);
const header = (
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandLabel="NODEDC MISSION CORE"
center={
<>
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
<HeaderNavigation
label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined}
items={roots.map((root) => ({ value: root.id, label: root.label }))}
onChange={selectRoot}
/>
</>
}
right={
<HeaderProfile>
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(runtime.backendStatus)}
</HeaderProfileButton>
<HeaderAvatar label="DC" />
</HeaderProfile>
}
/>
);
return (
<>
<ApplicationShell
data-nodedc-ui
className="control-station"
data-observation-fullscreen={observationFullscreenActive ? "true" : undefined}
navigationOpen={workspace.navigationOpen && activeRoot !== null}
contentOpen={workspace.contentOpen && activeDefinition !== null}
contentExpanded={workspace.contentExpanded}
header={header}
stage={
<LandingStage
root={currentRoot}
backendStatus={runtime.backendStatus}
phase={runtime.state?.phase}
message={runtime.state?.message}
onOpenObservation={() => openView("spatial-scene")}
onOpenDevice={() => openView("local-device")}
/>
}
navigation={currentRoot ? (
<AdminNavigationPanel
eyebrow="MISSION CORE"
title={currentRoot.title}
closeLabel={`Закрыть раздел «${currentRoot.label}»`}
navigationLabel={`Рабочие поверхности раздела «${currentRoot.label}»`}
onClose={workspace.closeNavigation}
contexts={[
{
id: "local-contour",
label: "Локальный контур",
description:
runtime.state?.activeDevice?.endpointLabel ||
selection?.model.displayName ||
"Модель не выбрана",
icon: <Icon name="network" />,
active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured",
},
]}
items={rootWorkspaces.map((item) => ({
id: item.id,
label: item.label,
icon: <Icon name={item.icon} />,
}))}
activeId={workspace.activeView ?? undefined}
onItemChange={openView}
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
<Icon name="activity" />
</span>
<span>{rootWorkspaces.length} рабочих поверхностей</span>
</>
}
/>
) : null}
content={activeDefinition ? (
<ApplicationPanel
key={activeDefinition.id}
eyebrow={activeDefinition.eyebrow}
title={
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
? `${activeDefinition.title}: ${recordedReplayLabel}`
: activeDefinition.title
}
description={activeDefinition.description}
expanded={workspace.contentExpanded}
onExpandedChange={workspace.setContentExpanded}
headerTools={
activeDefinition.kind === "device" ? (
<StatusBadge tone={phaseTone(runtime.state?.phase)}>
{phaseLabel(runtime.state?.phase)}
</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)}
/>
{layoutSaveNotice || workspaceLayoutProfile.error ? (
<span
className="workspace-layout-feedback"
data-error={workspaceLayoutProfile.error ? "true" : undefined}
role={workspaceLayoutProfile.error ? "alert" : "status"}
>
<i
className="api-dot"
data-status={workspaceLayoutProfile.error ? "error" : "online"}
aria-hidden="true"
/>
{layoutSaveNotice || workspaceLayoutProfile.error}
</span>
) : null}
</div>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
}
utilityActions={contentActions}
onClose={workspace.closeView}
>
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : (
<WorkspaceRenderer
definition={activeDefinition}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
recordedReplay={replayActive ? recordedReplay : null}
recordedSessionAdmission={recordedSessionAdmission}
sceneSettings={sceneSettings}
accumulationSeconds={displayDraft.accumulationSeconds}
onAccumulationChange={(accumulationSeconds) =>
stageDisplayPatch({ accumulationSeconds })}
onAccumulationCommit={flushDisplaySettings}
livePerceptionLayers={livePerceptionLayers}
onLivePerceptionLayersChange={changeLivePerceptionLayers}
observationLayout={observationLayout}
spatialControls={selection?.SpatialControlsView
? {
View: selection.SpatialControlsView,
model: selection.model,
}
: null}
navigation={{
openView,
openSource,
openDisplay,
openLayers,
activateAutomaticSpatialSource,
}}
/>
)}
</ApplicationPanel>
) : null}
/>
<Window
open={spatialWorkspaceActive && sourceWindowOpen}
title="Визуальный движок"
subtitle="Rerun gRPC и записи пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--sources"
data-scene-window="sources"
data-scene-active={activeSceneWindow === "sources" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("sources")}
onClose={() => closeSceneWindow("sources")}
footer={
<WindowFooterActions>
<Button
variant="ghost"
disabled={sourceSwitchBlocked}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceDraft("");
setSourceUrl("");
setRecordedReplay(null);
setRecordedReplayLabel(null);
}}
>
Сбросить адрес
</Button>
<Button
variant="primary"
shape="pill"
disabled={sourceSwitchBlocked || !sourceDraft.trim()}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
setRecordedReplayLabel(null);
}}
>
Применить адрес
</Button>
</WindowFooterActions>
}
>
<Inspector
defaultOpen={["connection"]}
singleOpen
sections={[
{
id: "connection",
label: "Подключение",
description: "Адрес потока или записи",
content: (
<div className="inspector-control-stack">
<ControlRow label="Состояние">
<span className="scene-window-state">
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
</span>
</ControlRow>
{effectiveSourceUrl ? (
<ControlRow label="Активный адрес" layout="stack">
<code className="scene-window-code">{effectiveSourceUrl}</code>
</ControlRow>
) : null}
<ControlRow label="Автоматический" layout="stack">
<code className="scene-window-code">
{automaticSourceUrl || "Локальный источник ещё не опубликован"}
</code>
</ControlRow>
<TextField
label="Ручной адрес"
hint="необязательно"
value={sourceDraft}
onChange={(event) => setSourceDraft(event.target.value)}
disabled={sourceSwitchBlocked}
spellCheck={false}
placeholder="rerun+http://127.0.0.1:9876/proxy"
description={sourceSwitchBlockedReason ?? "Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."}
/>
</div>
),
},
{
id: "formats",
label: "Поддерживаемые адреса",
description: "Rerun gRPC и RRD",
content: (
<div className="inspector-control-stack">
<ControlRow label="Живой поток" layout="stack">
<code className="scene-window-code">rerun+http://…/proxy</code>
</ControlRow>
<ControlRow label="Локальная запись" layout="stack">
<code className="scene-window-code">http://…/recording.rrd</code>
</ControlRow>
<ControlRow label="Удалённая запись" layout="stack">
<code className="scene-window-code">https://…/recording.rrd</code>
</ControlRow>
</div>
),
},
{
id: "adapter",
label: "Локальный адаптер",
description: "Транспорт устройства",
content: (
<div className="inspector-control-stack">
<ControlRow label="Контур">
<span className="scene-window-state">
<i className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(runtime.backendStatus)}
</span>
</ControlRow>
<p className="scene-window-note">
Встроенный адаптер публикует локальный Rerun gRPC автоматически после запуска
живого приёма или повтора записи. Ручной адрес для этого не требуется.
</p>
</div>
),
},
]}
/>
</Window>
<Window
open={spatialWorkspaceActive && displayWindowOpen}
title="Отображение"
subtitle="Параметры пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--display"
data-scene-window="display"
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("display")}
>
<Inspector
defaultOpen={["points"]}
singleOpen
sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
min={0.5}
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
/>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Атрибут цвета">
<Select
variant="split"
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
/>
</ControlRow>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Палитра">
<Select
variant="split"
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => stageDisplayPatch({ palette })}
/>
</ControlRow>
</div>
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => stageDisplayPatch({ customColor })}
/>
</ControlRow>
</div>
) : null}
{replayActive ? (
<p className="scene-window-note">
Первый новый режим читает индекс архивных точек. Следующие палитры
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
</p>
) : null}
</div>
),
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
min={0}
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
/>
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
<Checker
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
/>
</div>
</div>
),
},
{
id: "scene",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: (
<div className="inspector-control-stack">
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
</Window>
<Window
open={spatialWorkspaceActive && layerInspectorOpen}
title="Слои сцены"
subtitle="Сущности пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--layers"
data-scene-window="layers"
data-scene-active={activeSceneWindow === "layers" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("layers")}
onClose={() => closeSceneWindow("layers")}
>
<div className="layer-inspector-intro">
<span className="scene-window-state">
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
</span>
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
</div>
<Inspector
defaultOpen={["geometry"]}
singleOpen
sections={[
{
id: "geometry",
label: "Геометрия",
description: "Облако точек и путь",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div>
</div>
),
},
{
id: "frames",
label: "Координаты и камеры",
description: "Преобразования и области обзора",
content: (
<div className="inspector-control-stack">
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
{
id: "perception",
label: "Объекты и маски",
description: "Ожидают внешние обработчики",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
</Window>
</>
);
}