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({ navigationOpen: false, contentExpanded: true, }); const [activeRoot, setActiveRoot] = useState(null); const [sourceUrl, setSourceUrl] = useState(""); const [recordedReplay, setRecordedReplay] = useState(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState(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([]); const [layoutSaveNotice, setLayoutSaveNotice] = useState(null); const [sceneSettings, setSceneSettings] = useState(defaultSceneSettings); const [displayDraft, setDisplayDraft] = useState(defaultSceneSettings); const [livePerceptionLayers, setLivePerceptionLayers] = useState( 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(null); const sceneSettingsRef = useRef(defaultSceneSettings); const displayDraftRef = useRef(defaultSceneSettings); const confirmedSceneSettingsRef = useRef(defaultSceneSettings); const viewerSettingsCommitTimerRef = useRef(null); const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings); const livePerceptionLayersRef = useRef(defaultLivePerceptionLayers); const livePerceptionRevisionRef = useRef(0); const replayActiveRef = useRef(false); const sceneSettingsCommitterActiveRef = useRef(true); const sceneSettingsCommitterRef = useRef | 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({ 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) => { 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) => { 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((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) => 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(() => { 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 = ( } brandLabel="NODEDC MISSION CORE" center={ <> ({ value: root.id, label: root.label }))} onChange={selectRoot} /> } right={ void runtime.refresh()} title="Обновить локальный контур"> } /> ); return ( <> openView("spatial-scene")} onOpenDevice={() => openView("local-device")} /> } navigation={currentRoot ? ( , active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured", }, ]} items={rootWorkspaces.map((item) => ({ id: item.id, label: item.label, icon: , }))} activeId={workspace.activeView ?? undefined} onItemChange={openView} footer={ <> {rootWorkspaces.length} рабочих поверхностей } /> ) : null} content={activeDefinition ? ( {phaseLabel(runtime.state?.phase)} ) : activeDefinition.kind === "spatial" ? (
settleRecordedReplaySwitch(outcome)} /> {layoutSaveNotice || workspaceLayoutProfile.error ? (