1126 lines
47 KiB
TypeScript
1126 lines
47 KiB
TypeScript
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>
|
||
|
||
</>
|
||
);
|
||
}
|