Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5d52521f | ||
|
|
c804d89b18 | ||
|
|
76dc9f19c9 | ||
|
|
e515ab1b8c | ||
|
|
1c7dd29d8a | ||
|
|
be58d589e2 | ||
|
|
5f75afb720 | ||
|
|
99f29218d7 | ||
|
|
db14ee5435 | ||
|
|
81dd44906d | ||
|
|
d45b329de8 | ||
|
|
f164606828 | ||
|
|
556e376a62 | ||
|
|
1a2de8df65 | ||
|
|
b536f0049a | ||
|
|
3b85299fd9 | ||
|
|
505844adc3 | ||
|
|
89b0eeca3f | ||
|
|
1488a46794 | ||
|
|
e4339f4e88 | ||
|
|
7e257275ab | ||
|
|
a3c15e11e9 | ||
|
|
54a85fdf50 | ||
|
|
14f8375713 | ||
|
|
6492ebcdee | ||
|
|
e57511f975 | ||
|
|
f0802d2713 | ||
|
|
d8afb61229 | ||
|
|
421a8c515a | ||
|
|
a23c5b2005 | ||
|
|
1832943558 | ||
|
|
cc90709919 | ||
|
|
ee5d760c30 | ||
|
|
0e9faa6824 | ||
|
|
c6693d6f44 | ||
|
|
ed9a77ba73 | ||
|
|
7dd2c1c836 | ||
|
|
762d77ef95 | ||
|
|
9c4d70b0e9 | ||
|
|
eed53dbc0b | ||
|
|
4f333fc11c | ||
|
|
35a517f921 | ||
|
|
9bba44f7c4 | ||
|
|
9b6534287a | ||
|
|
9e03404f23 | ||
|
|
fa8ac760a1 | ||
|
|
309bdf759e | ||
|
|
ff00851216 | ||
|
|
ad24518b71 | ||
|
|
7ce09eabcd | ||
|
|
92b60de945 | ||
|
|
67398fef10 | ||
|
|
a93f1f1b8b | ||
|
|
102e93796b | ||
|
|
4f715e41d2 | ||
|
|
8f17a7e282 | ||
|
|
7187fd86bd | ||
|
|
e6521e855a | ||
|
|
06d216ac4a | ||
|
|
d7986cb62f | ||
|
|
a0732386f1 | ||
|
|
5c4546a2a6 | ||
|
|
ef1c1f50de | ||
|
|
2f80507bc0 | ||
|
|
d2dbfc9c85 | ||
|
|
3dffe39c89 | ||
|
|
77acb377e2 | ||
|
|
2fdf1d3cc4 | ||
|
|
d8cc5367c4 | ||
|
|
111fe3dcfb | ||
|
|
63ea2bed67 | ||
|
|
88e47fc39a | ||
|
|
cd16ea28a6 | ||
|
|
45019f8952 | ||
|
|
2e44e27967 | ||
|
|
2ccf172319 |
@@ -10,3 +10,4 @@
|
||||
*.las binary
|
||||
*.lcc binary
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -62,6 +62,40 @@ and the boundary between Mission Core and vendor-specific integration code.
|
||||
- Synthetic or explicitly redacted fixtures may be committed under
|
||||
`tests/fixtures/`.
|
||||
|
||||
## Insta360 and clean-host installation — owner requirement, 2026-09-08
|
||||
|
||||
- The current X4 starting point is USB enumeration only. SDK installation,
|
||||
camera initialization and live video have not been accepted.
|
||||
- Every Ubuntu change needed for Insta360 must execute through the shipped
|
||||
installer or the application's versioned device-preparation workflow from
|
||||
the first board experiment. Do not repair the board with an ad-hoc apt/pip
|
||||
install, copied library, chmod, environment override, service edit or root
|
||||
invocation and promise to package it later. Fix the product artifact first,
|
||||
then rerun that artifact. Read-only engineering inspection remains allowed.
|
||||
- Document every relevant action and its before/after evidence in
|
||||
`docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version,
|
||||
owning installer step, dependencies, idempotency, failure behavior and rollback.
|
||||
Keep private logs/identifiers/media out of Git and redact their public summary.
|
||||
- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency
|
||||
closure on the declared clean OS image and actual USB operation separately;
|
||||
installer, preparation and GUI acceptance must use the same shipped code.
|
||||
- X4 is operator live video and camera control (settings, photo, recording
|
||||
start/stop and file access), per the owner’s subsequent scope expansion.
|
||||
Preview and recording are separate operations. No stitching, AI, navigation
|
||||
or vehicle-control integration is admitted. Model support and physical device instances are separate: several
|
||||
cameras of the same model must retain independent identity and lifecycle.
|
||||
- Follow the scoped implementation plan in
|
||||
`docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan,
|
||||
not evidence that SDK compatibility, hardware capacity or installation passed.
|
||||
- Subsequent owner direction: the existing Ubuntu Mini is the available build
|
||||
and qualification machine; do not depend on Worker 006 or another host for
|
||||
this X4 work. Its existing GCC may compile through a versioned build artifact
|
||||
in bounded temporary staging. This is a build step, not runtime installation:
|
||||
no system changes, SDK execution or camera access during compilation. The
|
||||
final installer must carry the compiled payload so a clean operator Ubuntu
|
||||
does not inherit an undeclared compiler/build-cache prerequisite. All board
|
||||
staging/build/install/test actions remain documented and artifact-owned.
|
||||
|
||||
## Implementation order
|
||||
|
||||
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
||||
|
||||
Generated
+1
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"postinstall": "node scripts/install-rerun-navigation.mjs",
|
||||
"prebuild": "node scripts/install-rerun-navigation.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test test/*.test.mjs",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
|
||||
const manifest = JSON.parse(readFileSync(resolve(vendorRoot, "navigation-build.json"), "utf8"));
|
||||
const installed = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
if (installed.version !== "0.36.3" || manifest.upstreamVersion !== installed.version) {
|
||||
throw new Error("Rerun navigation requires the audited 0.36.3 package; rebase before upgrading");
|
||||
}
|
||||
if (sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))) !== manifest.patchSha256) {
|
||||
throw new Error("Rerun navigation source patch identity mismatch");
|
||||
}
|
||||
|
||||
const eyeType = "{ position: [number, number, number]; lookTarget: [number, number, number]; eyeUp: [number, number, number] } | null";
|
||||
const additions = {
|
||||
"index.js": {
|
||||
marker: " get_active_recording_id() {",
|
||||
code: ` get_camera_eye() {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.ts": {
|
||||
marker: " get_active_recording_id(): string | null {",
|
||||
code: ` get_camera_eye(): ${eyeType} {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.d.ts": {
|
||||
marker: " get_active_recording_id(): string | null;",
|
||||
code: ` get_camera_eye(): ${eyeType};\n`,
|
||||
},
|
||||
};
|
||||
|
||||
// Validate the complete set before changing any installed file. A package
|
||||
// upgrade, corrupt artifact or independently modified wrapper fails closed.
|
||||
const writes = [];
|
||||
for (const [name, identity] of Object.entries(manifest.files)) {
|
||||
const current = readFileSync(resolve(packageRoot, name));
|
||||
let output;
|
||||
if (additions[name]) {
|
||||
const { marker, code } = additions[name];
|
||||
const source = current.toString("utf8");
|
||||
const original = source.includes(code) ? source.replace(code, "") : source;
|
||||
if (sha(original) !== identity.upstreamSha256 || original.split(marker).length !== 2) {
|
||||
throw new Error(`Unexpected upstream wrapper: ${name}`);
|
||||
}
|
||||
output = Buffer.from(original.replace(marker, code + marker));
|
||||
} else {
|
||||
output = readFileSync(resolve(vendorRoot, identity.artifact));
|
||||
if (sha(output) !== identity.sha256) throw new Error(`Corrupt navigation artifact: ${name}`);
|
||||
if (![identity.upstreamSha256, identity.sha256].includes(sha(current))) {
|
||||
throw new Error(`Unexpected installed runtime: ${name}`);
|
||||
}
|
||||
}
|
||||
writes.push([resolve(packageRoot, name), output]);
|
||||
}
|
||||
for (const [path, output] of writes) writeFileSync(path, output);
|
||||
console.log("Installed NODE.DC Rerun 0.36.3 navigation/v1 (native camera, one renderer).");
|
||||
@@ -1,3 +1,5 @@
|
||||
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
|
||||
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
@@ -5,17 +7,14 @@ import {
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
Icon,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
@@ -29,6 +28,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel
|
||||
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
|
||||
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
|
||||
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
|
||||
import { useSessionOverviewMode } from "./core/observation/useSessionOverview";
|
||||
import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace";
|
||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
@@ -49,6 +50,7 @@ import type {
|
||||
} from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
|
||||
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||
import {
|
||||
@@ -66,33 +68,18 @@ import {
|
||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
|
||||
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
|
||||
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
||||
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;
|
||||
@@ -169,7 +156,9 @@ export default function App() {
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
@@ -194,6 +183,7 @@ export default function App() {
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const closeDisplayRef = useRef<() => void>(() => {});
|
||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||
@@ -241,6 +231,13 @@ export default function App() {
|
||||
);
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayPresented;
|
||||
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
|
||||
sceneSettingsRef.current = settings;
|
||||
displayDraftRef.current = settings;
|
||||
confirmedSceneSettingsRef.current = settings;
|
||||
setSceneSettings(settings);
|
||||
setDisplayDraft(settings);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||
@@ -323,6 +320,7 @@ export default function App() {
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
if (
|
||||
!remote ||
|
||||
replayActiveRef.current ||
|
||||
workspaceLayoutProfile.profile ||
|
||||
sceneSettingsCommitterRef.current?.isBusy()
|
||||
) return;
|
||||
@@ -345,7 +343,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
if (!profile) return;
|
||||
if (!profile || selectedRecordedSessionIdRef.current) return;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
@@ -416,7 +414,10 @@ export default function App() {
|
||||
|
||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "display") {
|
||||
setDisplayWindowOpen(false);
|
||||
closeDisplayRef.current();
|
||||
}
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
@@ -448,9 +449,10 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
|
||||
const definition = workspaceById(viewId);
|
||||
if (!definition) return;
|
||||
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
|
||||
setActiveRoot(definition.root);
|
||||
workspace.openView(viewId);
|
||||
};
|
||||
@@ -476,14 +478,22 @@ export default function App() {
|
||||
const next = { ...displayDraftRef.current, ...patch };
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
sessionDisplayProfile.edited();
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
}
|
||||
// Preview and persistence are separate. An open inspector must not freeze
|
||||
// either its controls or the shared accumulation slider below the scene.
|
||||
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(next);
|
||||
return;
|
||||
}
|
||||
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, viewerSettingsQuietPeriodMs);
|
||||
}, []);
|
||||
}, [sessionDisplayProfile.edited]);
|
||||
|
||||
const flushDisplaySettings = useCallback(() => {
|
||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||
@@ -493,8 +503,15 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
sessionDisplayProfile.edited();
|
||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||
}, [commitDisplaySettings]);
|
||||
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
|
||||
|
||||
closeDisplayRef.current = () => {
|
||||
const settings = {...displayDraftRef.current};
|
||||
commitDisplaySettings(settings);
|
||||
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
|
||||
};
|
||||
|
||||
const openDisplay = () => {
|
||||
if (!sceneWorkspaceActive) return;
|
||||
@@ -629,6 +646,7 @@ export default function App() {
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
||||
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
onAddVehicle,
|
||||
@@ -639,6 +657,7 @@ export default function App() {
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||
systemUtilityActions: computeContourSettings.utilityActions,
|
||||
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
|
||||
});
|
||||
|
||||
const header = (
|
||||
@@ -801,6 +820,8 @@ export default function App() {
|
||||
settleRecordedReplaySwitch(outcome)}
|
||||
onDeleteBegin={releaseRecordedReplayForDelete}
|
||||
/>
|
||||
) : activeDefinition.kind === "missions" ? (
|
||||
<div ref={setWorkspaceHeaderToolsHost} />
|
||||
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
@@ -819,15 +840,28 @@ export default function App() {
|
||||
utilityActions={contentActions}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
<PlanningCaptureGuard
|
||||
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
||||
onResume={() => openView('local-device', 'planning')}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
launchProfile === 'planning' ? <PlanningConnectionWindow>
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
</PlanningConnectionWindow> : <DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
|
||||
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
launchProfile={launchProfile}
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
headerToolsHost={workspaceHeaderToolsHost}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
@@ -868,6 +902,7 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PlanningCaptureGuard>
|
||||
</ApplicationPanel>
|
||||
) : null}
|
||||
/>
|
||||
@@ -1029,133 +1064,12 @@ export default function App() {
|
||||
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}
|
||||
{replayPresented ? (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
</Window>
|
||||
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
|
||||
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
|
||||
onDismiss={sessionDisplayProfile.dismissError} />
|
||||
|
||||
<Window
|
||||
open={sceneWorkspaceActive && layerInspectorOpen}
|
||||
@@ -1180,64 +1094,7 @@ export default function App() {
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
|
||||
</Window>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,92 +1 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EnvironmentBackground,
|
||||
EnvironmentMediaItem,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
||||
url: string;
|
||||
mediaKind: "image" | "video";
|
||||
};
|
||||
|
||||
function isReadyMediaItem(
|
||||
item: EnvironmentMediaItem,
|
||||
): item is ReadyEnvironmentMediaItem {
|
||||
return Boolean(item.url && item.mediaKind);
|
||||
}
|
||||
|
||||
export function EnvironmentBackgroundMedia({
|
||||
background,
|
||||
}: {
|
||||
background: EnvironmentBackground;
|
||||
}) {
|
||||
const items = useMemo(
|
||||
() => background.items.filter(isReadyMediaItem),
|
||||
[background.items],
|
||||
);
|
||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setFailedIds(new Set());
|
||||
}, [playlistIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!background.enabled
|
||||
|| !activeItem
|
||||
|| activeItem.mediaKind !== "image"
|
||||
|| playableItems.length < 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
||||
}, background.imageDurationSeconds * 1_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
activeItem,
|
||||
background.enabled,
|
||||
background.imageDurationSeconds,
|
||||
playableItems.length,
|
||||
]);
|
||||
|
||||
if (!background.enabled || !activeItem) return null;
|
||||
|
||||
return (
|
||||
<div className="landing-stage__media" aria-hidden="true">
|
||||
{activeItem.mediaKind === "video" ? (
|
||||
<video
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop={playableItems.length === 1}
|
||||
playsInline
|
||||
onEnded={() => setActiveIndex((current) => (
|
||||
(current + 1) % playableItems.length
|
||||
))}
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
alt=""
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { EnvironmentBackgroundMedia } from "@nodedc/ui-react";
|
||||
|
||||
@@ -1,248 +1 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
appendEnvironmentMediaItem,
|
||||
inferEnvironmentMediaKind,
|
||||
maxEnvironmentMediaItems,
|
||||
removeEnvironmentMediaItem,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaItem,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
interface EnvironmentMediaPlaylistEditorProps {
|
||||
surfaceId: EnvironmentSurfaceId;
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
].join(",");
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<EnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => (
|
||||
item.id === itemId ? { ...item, ...patch } : item
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
surfaceId,
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(
|
||||
() => [...background.items].reverse(),
|
||||
[background.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange(uploadingIds.size > 0);
|
||||
}, [onBusyChange, uploadingIds.size]);
|
||||
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
setItemError(
|
||||
itemId,
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить медиаконтент.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="environment-media-playlist">
|
||||
<div className="environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="environment-media-playlist__items"
|
||||
onReorder={(items) => onChange({
|
||||
...background,
|
||||
items: [...items].reverse(),
|
||||
})}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
return (
|
||||
<div className="environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url ?? ""}
|
||||
fileName={item.fileName}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={item.url}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
|
||||
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (
|
||||
playbackIndex === background.items.length - 1 ? error : null
|
||||
)}
|
||||
onSourceChange={(source) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="environment-media-playlist__empty">
|
||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="environment-media-playlist__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({
|
||||
...background,
|
||||
imageDurationSeconds,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
||||
перетаскивание меняет порядок.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { EnvironmentMediaPlaylistEditor } from "@nodedc/ui-react";
|
||||
|
||||
@@ -1,358 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
import { EnvironmentSettingsWindow as SharedEnvironmentSettingsWindow } from "@nodedc/ui-react";
|
||||
import { environmentSurfaceIds, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia } from "../core/environment/environmentSettings";
|
||||
import { roots, workspaces, workspacesForRoot } from "../productModel";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
|
||||
import {
|
||||
roots,
|
||||
workspaces,
|
||||
workspacesForRoot,
|
||||
} from "../productModel";
|
||||
|
||||
interface EnvironmentSettingsWindowProps {
|
||||
interface Props {
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
onUpload: (surfaceId: EnvironmentSurfaceId, itemId: string, file: File) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = useMemo(() => [
|
||||
{
|
||||
value: "home" as const,
|
||||
label: draft.pages.home.headerLabel,
|
||||
description: "Главная страница продукта",
|
||||
},
|
||||
...roots.map((root) => ({
|
||||
value: root.id,
|
||||
label: draft.pages[root.id].headerLabel,
|
||||
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
|
||||
})),
|
||||
], [draft.pages]);
|
||||
const quickActionWorkspaces = useMemo(
|
||||
() => surfaceId === "home"
|
||||
? workspaces.filter((workspace) => !workspace.internalOnly)
|
||||
: workspacesForRoot(surfaceId),
|
||||
[surfaceId],
|
||||
);
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "saving" || uploading;
|
||||
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedPage.primaryWorkspaceId
|
||||
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
|
||||
) {
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
||||
(page.background.enabled && !page.background.items.length)
|
||||
|| page.background.items.some((item) => {
|
||||
if (!item.url || !item.mediaKind) return true;
|
||||
if (item.source !== "url") return false;
|
||||
try {
|
||||
const parsed = new URL(item.url);
|
||||
return !["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
));
|
||||
if (invalidMedia) {
|
||||
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
|
||||
setLocalError(
|
||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Mission Core"
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: "Mission Core",
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: "MISSION CORE",
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.items.length) {
|
||||
setLocalError("Сначала добавьте медиаконтент.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings__editor">
|
||||
<div className="environment-settings__surface">
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__copy">
|
||||
<TextField
|
||||
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentMediaPlaylistEditor
|
||||
surfaceId={surfaceId}
|
||||
background={selectedBackground}
|
||||
disabled={busy}
|
||||
error={localError ?? error}
|
||||
onBusyChange={setUploading}
|
||||
onChange={(background) => {
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, background));
|
||||
}}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
const surfaces = environmentSurfaceIds.map(id => ({
|
||||
id, home: id === "home", description: id === "home" ? "Главная страница продукта" : `Стартовая страница раздела «${roots.find(root => root.id === id)?.label}»`,
|
||||
actions: id === "home" ? workspaces.filter(item => !item.internalOnly) : workspacesForRoot(id),
|
||||
}));
|
||||
export function EnvironmentSettingsWindow(props: Props) {
|
||||
return <SharedEnvironmentSettingsWindow {...props} productName="Mission Core" surfaces={surfaces} initialSurfaceId="fleet"
|
||||
onSave={draft => props.onSave({ ...draft, pages: Object.fromEntries(environmentSurfaceIds.map(id => [id, draft.pages[id]])) as EnvironmentSettings["pages"] })}
|
||||
onUpload={(id, itemId, file) => {
|
||||
if (!environmentSurfaceIds.includes(id as EnvironmentSurfaceId)) throw new Error("Страница окружения недоступна.");
|
||||
return props.onUpload(id as EnvironmentSurfaceId, itemId, file);
|
||||
}} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||
import type {RefObject} from "react";
|
||||
import {FloatingMediaWindow} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||
export {initialObservationWindowRect,shouldCaptureWorkspacePointer} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||
|
||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
@@ -10,60 +11,6 @@ import type {
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const WINDOW_WIDTH = 336;
|
||||
const WINDOW_HEIGHT = 210;
|
||||
const WINDOW_GAP = 16;
|
||||
const WINDOW_INSET = 18;
|
||||
const TIMELINE_CLEARANCE = 64;
|
||||
|
||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||
|
||||
export function shouldCaptureWorkspacePointer(
|
||||
button: number,
|
||||
target: ClosestTarget | null,
|
||||
): boolean {
|
||||
if (button !== 0 || !target) return false;
|
||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||
return !target.closest("button, input, select, textarea, a");
|
||||
}
|
||||
|
||||
export function initialObservationWindowRect(
|
||||
index: number,
|
||||
count = 1,
|
||||
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
||||
): ObservationWindowRect {
|
||||
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
||||
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
||||
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
||||
const maxColumns = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
||||
);
|
||||
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
||||
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
||||
const rowHeight = Math.max(
|
||||
1,
|
||||
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
||||
);
|
||||
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
||||
const row = Math.floor(index / columns);
|
||||
const column = index % columns;
|
||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||
|
||||
return {
|
||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||
y: Math.max(
|
||||
0,
|
||||
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function FloatingObservationWindow({
|
||||
source,
|
||||
index,
|
||||
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = boundsRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const measure = () => {
|
||||
const next = { width: element.clientWidth, height: element.clientHeight };
|
||||
setBounds((current) => (
|
||||
current?.width === next.width && current.height === next.height ? current : next
|
||||
));
|
||||
};
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [boundsRef]);
|
||||
|
||||
// Camera media admission must not wait for the viewport observer. A source
|
||||
// can arrive in the same commit as a workspace transition; mounting it with
|
||||
// the canonical fallback geometry keeps the browser transport alive until
|
||||
// the real bounds are measured immediately afterward.
|
||||
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
||||
const windowRect = rect ?? initialRect;
|
||||
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
boundsRef={boundsRef}
|
||||
rect={windowRect}
|
||||
onRectChange={onRectChange}
|
||||
maximized={maximized}
|
||||
onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
title={source.label}
|
||||
subtitle={source.description}
|
||||
status={(
|
||||
<span className="floating-observation-window__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
)}
|
||||
footer={(
|
||||
<span className="floating-observation-window__footer">
|
||||
<span>{source.endpointLabel || source.transport}</span>
|
||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||
</span>
|
||||
)}
|
||||
minWidth={Math.min(280, windowRect.width)}
|
||||
minHeight={Math.min(190, windowRect.height)}
|
||||
resizable={source.capabilities.resizable}
|
||||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture can fail if the browser ended the pointer between
|
||||
// dispatch and capture. The donor's window listeners remain fallback.
|
||||
}
|
||||
}}
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
return <FloatingMediaWindow title={source.label} subtitle={source.description}
|
||||
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
|
||||
active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
|
||||
status={<span className="floating-observation-window__status"><i data-availability={source.availability} aria-hidden="true"/>{observationSourceStatusLabel(source)}</span>}
|
||||
footer={<span className="floating-observation-window__footer"><span>{source.endpointLabel || source.transport}</span><span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span></span>}>
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
</FloatingMediaWindow>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import { LandingStage as SharedLandingStage, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { EnvironmentPage } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
|
||||
|
||||
export interface LandingStageProps {
|
||||
root: RootDefinition | null;
|
||||
@@ -25,40 +24,10 @@ export function LandingStage({
|
||||
quickActions,
|
||||
onOpenWorkspace,
|
||||
}: LandingStageProps) {
|
||||
const { background } = page;
|
||||
const hasMedia = background.enabled && background.items.some(
|
||||
(item) => item.url && item.mediaKind,
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="landing-stage"
|
||||
data-root={root?.id ?? "home"}
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackgroundMedia background={background} />
|
||||
<div className="landing-stage__shade" aria-hidden="true" />
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{quickActions.length ? (
|
||||
<div className="landing-stage__actions">
|
||||
{quickActions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
icon={<Icon name={workspace.icon} />}
|
||||
onClick={() => onOpenWorkspace(workspace.id)}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="landing-stage__status">
|
||||
<div>
|
||||
return <SharedLandingStage page={page} pageId={root?.id ?? "home"}
|
||||
actions={quickActions.map(workspace => ({ id: workspace.id, label: workspace.label, icon: workspace.icon, onSelect: () => onOpenWorkspace(workspace.id) }))}
|
||||
status={<>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</div>
|
||||
@@ -67,12 +36,9 @@ export function LandingStage({
|
||||
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
|
||||
</div>
|
||||
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
|
||||
</div>
|
||||
|
||||
<footer className="landing-stage__footer">
|
||||
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||
</>}
|
||||
footer={<>
|
||||
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
</>} />;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface CameraLeaseRetryBudget {
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
||||
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_BUFFERING_NOTICE_DELAY_MS = 1_000;
|
||||
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
||||
// main-thread stall. Keep one bounded append margin above that complete batch;
|
||||
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
||||
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
let onBufferError: (() => void) | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let bufferingNoticeTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
let playingReported = false;
|
||||
@@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({
|
||||
);
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
const clearBufferingNotice = () => {
|
||||
if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer);
|
||||
bufferingNoticeTimer = undefined;
|
||||
};
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
@@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
return;
|
||||
}
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
@@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
|
||||
const onPlaying = () => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
clearBufferingNotice();
|
||||
startupWatchdog?.markPlaying();
|
||||
transportHealthyRef.current = true;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
@@ -410,7 +419,21 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
};
|
||||
|
||||
const onWaiting = () => {
|
||||
if (!transportIsCurrent() || failed || !playingReported) return;
|
||||
// A short receipt gap must not flicker or restart the acquisition-owned
|
||||
// stream. A sustained decoder wait must not retain a playing presentation.
|
||||
if (bufferingNoticeTimer !== undefined) return;
|
||||
bufferingNoticeTimer = window.setTimeout(() => {
|
||||
bufferingNoticeTimer = undefined;
|
||||
if (!transportIsCurrent() || failed || video.readyState >= 3) return;
|
||||
setStatus("buffering");
|
||||
setMessage("Ожидание новых кадров.");
|
||||
}, CAMERA_BUFFERING_NOTICE_DELAY_MS);
|
||||
};
|
||||
video.addEventListener("playing", onPlaying);
|
||||
video.addEventListener("waiting", onWaiting);
|
||||
video.addEventListener("stalled", onWaiting);
|
||||
|
||||
const appendNext = () => {
|
||||
if (
|
||||
@@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({
|
||||
disposed = true;
|
||||
transportHealthyRef.current = false;
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Browser preview transport replaced or hidden");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
video.removeEventListener("waiting", onWaiting);
|
||||
video.removeEventListener("stalled", onWaiting);
|
||||
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
||||
if (sourceBuffer && onBufferUpdateEnd) {
|
||||
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
||||
@@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
{status !== "playing" ? (
|
||||
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
||||
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
||||
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
|
||||
<strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
|
||||
<span>{message}</span>
|
||||
{status === "error" ? (
|
||||
<button
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: "source",
|
||||
scope: "standalone",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
||||
import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
@@ -16,28 +16,6 @@ import type {
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
video: "video",
|
||||
image: "image",
|
||||
depth: "image",
|
||||
};
|
||||
|
||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||
unverified: "Не подтверждён",
|
||||
declared: "Канал объявлен",
|
||||
available: "Доступен",
|
||||
connecting: "Подключение",
|
||||
streaming: "Эфир",
|
||||
degraded: "Нестабильно",
|
||||
unavailable: "Недоступен",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
||||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({
|
||||
source,
|
||||
playback,
|
||||
@@ -159,105 +137,3 @@ export function ObservationMedia({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationSourcePicker({
|
||||
sources,
|
||||
visibleSourceIds,
|
||||
pendingSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
sources: readonly ObservationSourceDescriptor[];
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
pendingSourceIds?: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
||||
}) {
|
||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="scene-source-picker"
|
||||
placement="bottom-start"
|
||||
width={340}
|
||||
offset={10}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-source-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="scene-source-picker__trigger"
|
||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||
aria-label="Источники данных сцены"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={17} />
|
||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<header className="observation-source-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||
<strong>Источники данных</strong>
|
||||
</div>
|
||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||
</header>
|
||||
{sources.length ? (
|
||||
<div className="observation-source-menu__list">
|
||||
{sources.map((source) => {
|
||||
const selected = visibleSourceIds.has(source.id);
|
||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
||||
const canOpen = Boolean(
|
||||
selected ||
|
||||
source.modality === "point-cloud" ||
|
||||
source.previewUrl ||
|
||||
source.delivery ||
|
||||
source.activation?.controllable,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-source-option"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
disabled={pending || !canOpen}
|
||||
onClick={() => void onToggle(source.id)}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__check">
|
||||
{selected ? <Icon name="check" size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-source-menu__empty">
|
||||
<Icon name="database" size={18} />
|
||||
<strong>Каталог пока пуст</strong>
|
||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||
модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,197 +1 @@
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
export function ObservationTimeline({
|
||||
active,
|
||||
sourceCount,
|
||||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
rangeNs = null,
|
||||
currentNs = null,
|
||||
playing = false,
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
showJumpToEnd = true,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
sourceCount: number;
|
||||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
rangeNs?: { min: number; max: number } | null;
|
||||
currentNs?: number | null;
|
||||
playing?: boolean;
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
showJumpToEnd?: boolean;
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||
const buffered = Boolean(
|
||||
seekable &&
|
||||
(mode === "buffered" || mode === "recorded") &&
|
||||
playbackRange,
|
||||
);
|
||||
const elapsedSeconds = playbackRange
|
||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||
: 0;
|
||||
const durationSeconds = playbackRange
|
||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||
: 0;
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
const accumulationValue = accumulationSeconds === undefined
|
||||
? null
|
||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||
>
|
||||
{accumulationValue !== null ? (
|
||||
<div className="observation-timeline__accumulation">
|
||||
<span>Накопление</span>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||
onChange={(event) =>
|
||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||
onPointerUp={onAccumulationCommit}
|
||||
onTouchEnd={onAccumulationCommit}
|
||||
onKeyUp={onAccumulationCommit}
|
||||
onBlur={onAccumulationCommit}
|
||||
/>
|
||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="observation-timeline__playback">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Перейти к началу"
|
||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
className="observation-timeline__transport"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
/>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
value={String(playbackRate)}
|
||||
options={[
|
||||
{ value: "0.5", label: "0,5×" },
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="inline"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(durationSeconds, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Позиция воспроизведения"
|
||||
onChange={(event) => {
|
||||
if (!playbackRange) return;
|
||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>
|
||||
{buffered
|
||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||
: active ? "LIVE" : "—:—:—.———"}
|
||||
</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
{showJumpToEnd ? (
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
range: { min: number; max: number } | null | undefined,
|
||||
): { min: number; max: number } | null {
|
||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||
if (range.max <= range.min) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
export function timelineOffsetSeconds(
|
||||
range: { min: number; max: number },
|
||||
currentNs: number,
|
||||
): number {
|
||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||
}
|
||||
|
||||
export function formatTimelineDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||
const wholeMinutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - wholeMinutes * 60;
|
||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./rerun/recordedRerunCameraJournal";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
|
||||
import {
|
||||
advanceLiveReceiverOpenWatchdog,
|
||||
advanceLiveReceiverWatchdog,
|
||||
@@ -130,6 +131,7 @@ export interface RerunViewportProps {
|
||||
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
|
||||
sceneSettings?: Pick<
|
||||
SceneSettings,
|
||||
| "pointDecimationPercent"
|
||||
| "accumulationSeconds"
|
||||
| "showGrid"
|
||||
| "showPoints"
|
||||
@@ -148,14 +150,8 @@ interface RerunBlueprintChannel {
|
||||
cameraContract?: string | null;
|
||||
appliedFollowTrajectory?: boolean | null;
|
||||
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
|
||||
configureCameraJournal?: (
|
||||
eye: RecordedRerunCameraEye,
|
||||
spatialViewportStart: number,
|
||||
) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye;
|
||||
setCameraViewportStart?: (spatialViewportStart: number) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye | null;
|
||||
getCurrentTimeNs?: () => number | null;
|
||||
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
channel: {
|
||||
readonly ready: boolean;
|
||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||
@@ -421,6 +417,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
currentTimeNs,
|
||||
reactivateUpdates = false,
|
||||
onCameraMaxOrbitalRadius,
|
||||
displayPointBank,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
@@ -444,12 +441,17 @@ export async function fetchRecordedBlueprintRrd(
|
||||
currentTimeNs?: number | null;
|
||||
reactivateUpdates?: boolean;
|
||||
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
displayPointBank?: string | null;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
const resolvedUnifiedPerception =
|
||||
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
|
||||
// Rerun exposes its interpolated TimeReal cursor as f64 nanoseconds, even
|
||||
// when paused after seeking. The API needs an integer pose-query timestamp.
|
||||
// Quantize at this boundary; never round the viewer's actual playback cursor.
|
||||
const requestTimeNs = currentTimeNs == null ? currentTimeNs : Math.round(currentTimeNs);
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
@@ -481,7 +483,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
...cameraEye.eyeUp,
|
||||
].some((value) => !Number.isFinite(value))) ||
|
||||
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
||||
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
|
||||
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
|
||||
)) ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
@@ -508,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
application_id: identity.applicationId,
|
||||
recording_id: identity.recordingId,
|
||||
blueprint_session_id: blueprintSessionId,
|
||||
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
show_grid: settings.showGrid,
|
||||
show_points: settings.showPoints,
|
||||
@@ -528,7 +531,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
eye_up: cameraEye?.eyeUp ?? null,
|
||||
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
|
||||
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
||||
current_time_ns: currentTimeNs,
|
||||
current_time_ns: requestTimeNs,
|
||||
}),
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||
@@ -585,10 +588,8 @@ export function recordedCameraJournalContract(
|
||||
followTrajectory: boolean;
|
||||
},
|
||||
): string {
|
||||
// Following is a tracking property of the current native eye. It must never
|
||||
// initialize the browser journal again: doing so replaces the operator's
|
||||
// current pose with the startup preset immediately before the blueprint is
|
||||
// sent. Plan/3D and explicit reset are the only preset transitions.
|
||||
// Following uses the native eye snapshot, never the startup preset.
|
||||
// Plan/3D and explicit reset are the only preset transitions.
|
||||
return [activeView, viewResetGeneration, planView].join(":");
|
||||
}
|
||||
|
||||
@@ -762,7 +763,15 @@ export async function probeRecordedPerceptionViewerSource(
|
||||
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
export function RerunViewport(props: RerunViewportProps) {
|
||||
// Even when source URLs coincide, another profile cannot inherit a mounted
|
||||
// viewer's playback, blueprint channels, recovery state or scene draft.
|
||||
const identity = props.profile.kind === "laboratory-result"
|
||||
? `${props.profile.kind}:${props.profile.resultId}` : props.profile.kind;
|
||||
return <RerunViewportInstance key={identity} {...props} />;
|
||||
}
|
||||
|
||||
function RerunViewportInstance({
|
||||
profile,
|
||||
onStatusChange,
|
||||
onSelectionChange,
|
||||
@@ -772,7 +781,7 @@ export function RerunViewport({
|
||||
onPerceptionLoadChange,
|
||||
onPointColorLoadChange,
|
||||
}: RerunViewportProps) {
|
||||
const recordedProfile = profile.kind === "recorded-session" ? profile : null;
|
||||
const recordedProfile = profile.kind === "recorded-session" || profile.kind === "laboratory-result" ? profile : null;
|
||||
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
||||
const sourceUrl = profile.sourceUrl;
|
||||
const recordedArtifact = recordedProfile?.artifact ?? null;
|
||||
@@ -833,6 +842,7 @@ export function RerunViewport({
|
||||
const presentationGateRef = useRef(presentationGate);
|
||||
presentationGateRef.current = presentationGate;
|
||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
|
||||
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||
const recordedBlueprintUrl = sourceUrl
|
||||
? resolveRecordedBlueprintUrl(
|
||||
@@ -849,6 +859,17 @@ export function RerunViewport({
|
||||
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
||||
const pointDisplay = useRecordedPointDisplay({
|
||||
endpoint: recordedBlueprintUrl, settings: sceneSettings, revision: blueprintChannelRevision,
|
||||
sourceGeneration: recordedArtifact?.sha256,
|
||||
ready: status === 'ready' && pointDisplayVisibilityGate === `${recordedBlueprintUrl}:${blueprintChannelRevision}`,
|
||||
onLoad: onPointColorLoadChange,
|
||||
getOwner: () => {
|
||||
const active = blueprintChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
return active?.channel.ready && identity ? {identity, send: bytes => active.channel.send_rrd(bytes)} : null;
|
||||
},
|
||||
});
|
||||
const presentationStatus = rerunPresentationStatus(
|
||||
status,
|
||||
presentationGate,
|
||||
@@ -1630,29 +1651,14 @@ export function RerunViewport({
|
||||
cameraContract: null,
|
||||
appliedFollowTrajectory: null,
|
||||
pendingFollowCameraEye: null,
|
||||
configureCameraJournal: (eye, spatialViewportStart) => {
|
||||
if ("configure_camera_journal" in viewer) {
|
||||
viewer.configure_camera_journal(eye, spatialViewportStart);
|
||||
}
|
||||
},
|
||||
getCameraEye: () => "get_camera_eye" in viewer
|
||||
? viewer.get_camera_eye()
|
||||
: RECORDED_RERUN_ORBITAL_EYE,
|
||||
setCameraViewportStart: (spatialViewportStart) => {
|
||||
if ("set_camera_viewport_start" in viewer) {
|
||||
viewer.set_camera_viewport_start(spatialViewportStart);
|
||||
}
|
||||
},
|
||||
: null,
|
||||
getCurrentTimeNs: () => {
|
||||
const currentIdentity = recordedIdentityRef.current;
|
||||
if (!currentIdentity) return null;
|
||||
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
|
||||
},
|
||||
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if ("set_camera_max_orbital_radius" in viewer) {
|
||||
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
channel,
|
||||
};
|
||||
blueprintChannelRef.current = blueprintChannel;
|
||||
@@ -2002,6 +2008,7 @@ export function RerunViewport({
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedPointColorsUrl || !sceneSettings) return;
|
||||
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
|
||||
const active = blueprintChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
if (
|
||||
@@ -2081,6 +2088,7 @@ export function RerunViewport({
|
||||
sceneSettings?.colorMode,
|
||||
sceneSettings?.customColor,
|
||||
sceneSettings?.palette,
|
||||
sceneSettings?.pointDecimationPercent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2105,12 +2113,6 @@ export function RerunViewport({
|
||||
},
|
||||
);
|
||||
const cameraContractChanged = active.cameraContract !== cameraContract;
|
||||
if (cameraContractChanged) {
|
||||
active.configureCameraJournal?.(
|
||||
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
}
|
||||
const abort = new AbortController();
|
||||
const previousFollow = active.appliedFollowTrajectory ?? false;
|
||||
const enablingFollow = recordedFollowTrajectory && !previousFollow;
|
||||
@@ -2135,7 +2137,9 @@ export function RerunViewport({
|
||||
if (eyeRelativeToTracking && currentTimeNs == null) {
|
||||
throw new Error("Recorded tracking cursor is unavailable");
|
||||
}
|
||||
return fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||
return fetchRecordedBlueprintRrd(recordedBlueprintUrl,
|
||||
{...sceneSettings, showPoints: sceneSettings.showPoints && !pointDisplay.hidePoints}, identity, {
|
||||
displayPointBank: pointDisplay.bank,
|
||||
origin: window.location.origin,
|
||||
blueprintSessionId: blueprintSessionIdRef.current,
|
||||
signal: abort.signal,
|
||||
@@ -2149,13 +2153,11 @@ export function RerunViewport({
|
||||
planView: recordedPlanView,
|
||||
cameraEye,
|
||||
eyeRelativeToTracking,
|
||||
currentTimeNs,
|
||||
// Only following-eye transitions need a pose at the cursor. Supplying
|
||||
// time for display-only updates makes the API scan the entire archive
|
||||
// for camera bounds, although neither the camera nor its pose changed.
|
||||
currentTimeNs: eyeRelativeToTracking ? currentTimeNs : undefined,
|
||||
reactivateUpdates,
|
||||
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if (blueprintChannelRef.current === active) {
|
||||
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const canApply = () => (
|
||||
@@ -2167,24 +2169,29 @@ export function RerunViewport({
|
||||
const applyPayload = (payload: Uint8Array) => {
|
||||
if (!canApply()) return false;
|
||||
active.channel.send_rrd(payload);
|
||||
active.setCameraViewportStart?.(
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
// Exclude inactive point generations before admitting their data into
|
||||
// cached recordings whose embedded blueprint predates display controls.
|
||||
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
|
||||
return true;
|
||||
};
|
||||
void (async () => {
|
||||
const firstEye = disablingFollow
|
||||
? transitionEye ?? undefined
|
||||
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
|
||||
? pendingFollowEye ?? active.getCameraEye?.()
|
||||
: undefined;
|
||||
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
|
||||
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
|
||||
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
|
||||
disablingFollow || recordedFollowTrajectory
|
||||
);
|
||||
const firstPayload = await requestBlueprint(
|
||||
firstEye,
|
||||
firstEyeIsTrackingRelative,
|
||||
enablingFollow || disablingFollow || Boolean(pendingFollowEye),
|
||||
// Upstream copies incoming blueprints into an active store. Every
|
||||
// settings update must activate it; merely appending rows is invisible.
|
||||
// Activation copies the incoming blueprint store, not the operator's
|
||||
// edited store. Preserve its actual native eye on display-only updates;
|
||||
// omitting the eye resurrects the embedded startup camera.
|
||||
true,
|
||||
);
|
||||
if (!applyPayload(firstPayload)) return;
|
||||
active.cameraContract = cameraContract;
|
||||
@@ -2205,9 +2212,10 @@ export function RerunViewport({
|
||||
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
|
||||
if (!applyPayload(stabilizedPayload)) return;
|
||||
active.pendingFollowCameraEye = null;
|
||||
})().catch(() => {
|
||||
})().catch((error: unknown) => {
|
||||
// The recording remains usable with its embedded default blueprint.
|
||||
// A later settings change retries through the same small channel.
|
||||
if (!abort.signal.aborted) console.warn("Recorded scene settings were not applied", error);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [
|
||||
@@ -2235,6 +2243,9 @@ export function RerunViewport({
|
||||
sceneSettings?.showPoints,
|
||||
sceneSettings?.showTrajectory,
|
||||
sceneSettings?.showGrid,
|
||||
pointDisplay.ready,
|
||||
pointDisplay.bank,
|
||||
pointDisplay.hidePoints,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
||||
import {
|
||||
@@ -305,7 +305,8 @@ export function CanonicalResultRerunReplay({
|
||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||
const semanticVisible = showDDRNet || showEoMT;
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
const profile = launch ? laboratoryResultRerunProfile({
|
||||
resultId,
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {MapVisualSettings} from '../../core/map/mapView';
|
||||
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
|
||||
|
||||
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
|
||||
return {
|
||||
atmosphere_enabled: settings.atmosphereEnabled,
|
||||
atmosphere_hue: settings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
settings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
settings.atmosphereBrightness,
|
||||
fog_enabled: settings.fogEnabled,
|
||||
fog_density: settings.fogDensity,
|
||||
sun_enabled: settings.sunEnabled,
|
||||
sun_hour: settings.sunHour,
|
||||
sun_intensity: settings.sunIntensity,
|
||||
shadows_enabled: settings.shadowsEnabled,
|
||||
monochrome_enabled: settings.monochromeEnabled,
|
||||
monochrome_color: settings.monochromeColor,
|
||||
imagery_brightness: settings.imageryBrightness,
|
||||
imagery_contrast: settings.imageryContrast,
|
||||
imagery_saturation: settings.imagerySaturation,
|
||||
imagery_gamma: settings.imageryGamma,
|
||||
imagery_hue: settings.imageryHue,
|
||||
imagery_alpha: settings.imageryAlpha,
|
||||
globe_color: settings.globeColor,
|
||||
background_color: settings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
settings.terrainExaggeration,
|
||||
buildings_color: settings.buildingsColor,
|
||||
buildings_opacity: settings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
settings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: settings.gridLodEnabled,
|
||||
grid_height_meters: settings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
settings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
settings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
settings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
settings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
settings.gridLod3StepKm,
|
||||
grid_radius_km: settings.gridRadiusKm,
|
||||
grid_line_width: settings.gridLineWidth,
|
||||
grid_color: settings.gridColor,
|
||||
grid_opacity: settings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
settings.gridDotsEnabled,
|
||||
grid_dots_size: settings.gridDotsSize,
|
||||
grid_dots_color: settings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
settings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
settings.cameraAnimationEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
|
||||
import type { RouteCheck } from "../../core/missions/planner";
|
||||
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
|
||||
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
|
||||
}) {
|
||||
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
|
||||
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
|
||||
<LoadingRegion loading={busy} label="Проверка исходных данных">
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{report && <><dl>
|
||||
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
|
||||
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
|
||||
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
|
||||
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
|
||||
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
|
||||
{report.warnings.map(w => <p key={w}>{w}</p>)}
|
||||
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
|
||||
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
|
||||
</LoadingRegion>
|
||||
</div></Window>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { RangeControl } from "@nodedc/ui-react";
|
||||
import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner";
|
||||
/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */
|
||||
export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
useEffect(() => setCursor(0), [poses]);
|
||||
const projection = useMemo(() => {
|
||||
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
|
||||
for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); }
|
||||
const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin));
|
||||
const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale];
|
||||
const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" ");
|
||||
return { project, reference: line(source.poses), selected: line(poses), scale };
|
||||
}, [source, poses]);
|
||||
const point = poses[Math.min(cursor, poses.length - 1)];
|
||||
if (!point) return <div className="session-overview__empty">Выберите участок траектории.</div>;
|
||||
const [x, y] = projection.project(point);
|
||||
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
|
||||
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
|
||||
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
|
||||
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
|
||||
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
|
||||
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
|
||||
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
|
||||
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
|
||||
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {ReactNode} from "react";
|
||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
||||
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
|
||||
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
|
||||
const { data, error, retry } = useSessionOverview(sessionId);
|
||||
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
||||
const failure = error || (data?.state === "error" ? data.message : null);
|
||||
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
|
||||
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
|
||||
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {Button, StatusBadge} from '@nodedc/ui-react';
|
||||
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
|
||||
/** Composition-level handoff; never sends a device or acquisition command. */
|
||||
export function PlanningCaptureGuard({enabled, onResume, children}: {
|
||||
enabled: boolean;
|
||||
onResume: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const planning = usePlanningTest();
|
||||
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
|
||||
return <div className="mission-planner__check">
|
||||
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
|
||||
<p>{planning.test!.draft.name}</p>
|
||||
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
|
||||
{planning.error && <p role="alert">{planning.error}</p>}
|
||||
<div className="mission-planner__actions">
|
||||
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
|
||||
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useEffect,useState,type ReactNode} from 'react';
|
||||
import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
|
||||
/** Wrap the existing plugin connection flow, retaining its acquisition authority. */
|
||||
export function PlanningConnectionWindow({children}:{children:ReactNode}){
|
||||
const p=usePlanningTest(); const [open,setOpen]=useState(true);
|
||||
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
|
||||
useEffect(()=>{
|
||||
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
|
||||
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
|
||||
if(models.length===1)void selectModel(models[0].model.id);
|
||||
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
|
||||
if(!p.test)return <>{children}</>;
|
||||
const t=p.test, terminal=planningTestTerminal(t);
|
||||
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
|
||||
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
|
||||
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
|
||||
return <div className="mission-planner__check">
|
||||
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
|
||||
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
|
||||
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
|
||||
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
|
||||
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
|
||||
<div className="mission-planner__check">{details}
|
||||
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
|
||||
: terminal?<p>{t.message}</p>:children}
|
||||
</div>
|
||||
</Window>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react';
|
||||
import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost';
|
||||
import {plannerBase} from '../../core/missions/planner';
|
||||
import {startPlanningSceneStream} from '../../core/missions/planningSceneStream';
|
||||
import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry';
|
||||
|
||||
function nextAnimationFrame(limitMs=500):Promise<number|null>{
|
||||
return new Promise(resolve=>{
|
||||
let frame:number|undefined,settled=false;
|
||||
const finish=(value:number|null)=>{
|
||||
if(settled)return;
|
||||
settled=true;
|
||||
if(frame!==undefined&&value===null)cancelAnimationFrame(frame);
|
||||
clearTimeout(timeout);resolve(value);
|
||||
};
|
||||
const timeout=setTimeout(()=>finish(null),limitMs);
|
||||
frame=requestAnimationFrame(()=>finish(performance.now()));
|
||||
});
|
||||
}
|
||||
|
||||
/** One persistent viewer/channel. Updates replace entities without resetting the camera. */
|
||||
type HeightBounds={min:number;max:number};
|
||||
const CLIP_CEILING_M=80;
|
||||
export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record<string,string|number|boolean>;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){
|
||||
const optionsRef=useRef(options);optionsRef.current=options;
|
||||
const stateRef=useRef({active,revision});stateRef.current={active,revision};
|
||||
const ceilingRef=useRef<number|null>(null);
|
||||
const boundsRef=useRef<{min:number;max:number}|null>(null);
|
||||
const host=useRef<HTMLDivElement>(null);
|
||||
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
|
||||
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
|
||||
const [ceiling,setCeiling]=useState<number|null>(null);
|
||||
useEffect(()=>{
|
||||
ceilingRef.current=null;
|
||||
const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null;
|
||||
boundsRef.current=next;
|
||||
setBounds(next);setCeiling(null);
|
||||
},[runId,heightBounds?.min,heightBounds?.max]);
|
||||
useEffect(()=>{
|
||||
if(!host.current)return;
|
||||
let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined;
|
||||
const reporter=createPlanningPresentationReporter({
|
||||
url:`${plannerBase}/live-tests/${runId}/presentation-observations`,
|
||||
});
|
||||
const runtime=createIsolatedRerunHost(host.current);setState('loading');
|
||||
const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000);
|
||||
void runtime.ready.then(async({viewer,mount})=>{
|
||||
if(disposed)return;
|
||||
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false});
|
||||
if(disposed)return;
|
||||
const channel=viewer.open_channel('planning-'+runId);
|
||||
viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}});
|
||||
stopStream=startPlanningSceneStream({
|
||||
url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`,
|
||||
snapshot:()=>({...stateRef.current,options:{...optionsRef.current,
|
||||
...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}),
|
||||
apply:async(bytes,delivery)=>{
|
||||
if(disposed)return;
|
||||
if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){
|
||||
const next={min:delivery.heightMinM,max:delivery.heightMaxM};
|
||||
const previous=boundsRef.current;
|
||||
if(!previous||previous.min!==next.min||previous.max!==next.max){
|
||||
boundsRef.current=next;setBounds(next);
|
||||
if(ceilingRef.current!==null&&(ceilingRef.current<next.min||ceilingRef.current>next.max)){
|
||||
ceilingRef.current=null;setCeiling(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
sceneAvailable=true;
|
||||
const admissionStarted=performance.now();
|
||||
channel.send_rrd(bytes);
|
||||
const admissionEnded=performance.now();
|
||||
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
||||
if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');}
|
||||
// Rerun exposes no canvas-paint receipt. Two bounded browser frame
|
||||
// opportunities are retained as an explicit proxy, never as a GPU claim.
|
||||
const first=await nextAnimationFrame();
|
||||
const second=first===null?null:await nextAnimationFrame();
|
||||
if(!disposed)reporter.record(delivery,{
|
||||
rerunAdmissionMs:admissionEnded-admissionStarted,
|
||||
firstAnimationFrameMs:first===null?null:first-admissionEnded,
|
||||
secondAnimationFrameMs:second===null?null:second-admissionEnded,
|
||||
});
|
||||
},
|
||||
error:()=>{sceneAvailable=false;if(!disposed)setState('error');},
|
||||
});
|
||||
}).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}});
|
||||
return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();};
|
||||
},[runId,retry]);
|
||||
const sourceBounds=bounds??heightBounds;
|
||||
const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null;
|
||||
return <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
|
||||
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
|
||||
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
|
||||
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
|
||||
formatValue={value=>`${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')}
|
||||
onChange={value=>{
|
||||
const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value;
|
||||
ceilingRef.current=next;setCeiling(next);
|
||||
}}/>
|
||||
</div>}
|
||||
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
|
||||
</LoadingRegion>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react';
|
||||
import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects';
|
||||
|
||||
export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) {
|
||||
const r=p.result;
|
||||
return <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
|
||||
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
|
||||
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
|
||||
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
|
||||
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
|
||||
</div>},
|
||||
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
|
||||
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
|
||||
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
|
||||
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
|
||||
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
|
||||
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
|
||||
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
|
||||
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
|
||||
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
|
||||
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
|
||||
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
|
||||
</div>},
|
||||
]}/>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react';
|
||||
import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects';
|
||||
|
||||
export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: {
|
||||
items: PlanningProject[];
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string) => void;
|
||||
onRemove: (project: PlanningProject) => Promise<void>;
|
||||
}) {
|
||||
const [target, setTarget] = useState<PlanningProject | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
return <>
|
||||
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
|
||||
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
|
||||
value: item.key, label: item.name,
|
||||
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
|
||||
action: {
|
||||
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
|
||||
icon: <Icon name="trash" />,
|
||||
tone: 'danger' as const,
|
||||
disabled: !planningProjectDeletable(item),
|
||||
onAction: () => { setError(null); setTarget(item); },
|
||||
},
|
||||
}))]}
|
||||
onChange={key => { if (key) onChange(key); }} />
|
||||
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
|
||||
description={target ? <>
|
||||
<strong>{target.name}</strong>
|
||||
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
|
||||
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
|
||||
</> : null}
|
||||
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
|
||||
onClose={() => { if (!busy) setTarget(null); }}
|
||||
onConfirm={async () => {
|
||||
if (!target || busy) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await onRemove(target); setTarget(null); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
|
||||
finally { setBusy(false); }
|
||||
}} />
|
||||
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
|
||||
onDismiss={() => setError(null)} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
|
||||
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
|
||||
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||
|
||||
export function PlanningProjectSettings({p,onStart,starting}:{
|
||||
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
|
||||
}) {
|
||||
const disabled=p.busy||starting;
|
||||
const length=routeLength(p.poses);
|
||||
const routeReady=canStartPlanningRoute(length,'scanner');
|
||||
return <><Inspector variant="panel" defaultOpen={['project','zone']} sections={[
|
||||
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
|
||||
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
|
||||
</div>},
|
||||
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
|
||||
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
|
||||
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
|
||||
{p.source&&<>
|
||||
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
|
||||
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
|
||||
</>}
|
||||
</div>},
|
||||
]}/>
|
||||
<div className="inspector-control-stack">
|
||||
{p.source&&!routeReady&&<small>В записи недостаточно перемещения для привязки. Выберите другой эталон.</small>}
|
||||
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!routeReady} onClick={onStart}><Icon name="play"/>Начать новый проход</Button>
|
||||
{p.error&&<p role="alert">{p.error}</p>}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useState,type ReactNode,type RefObject} from 'react';
|
||||
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
|
||||
|
||||
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
|
||||
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
|
||||
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
|
||||
}){
|
||||
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
|
||||
const [maximized,setMaximized]=useState(false);
|
||||
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
|
||||
title={title} maximized={maximized} onMaximizedChange={setMaximized}
|
||||
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
|
||||
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
|
||||
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
|
||||
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
|
||||
}}>
|
||||
<div className="mission-planner__check">{children}</div>
|
||||
</WorkspaceWindow>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||
|
||||
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
|
||||
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
let disposed = false; setState("loading");
|
||||
const runtime = createIsolatedRerunHost(host.current);
|
||||
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||
if (disposed) return;
|
||||
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
|
||||
});
|
||||
if (disposed) return;
|
||||
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
|
||||
}, [sourceUrl, retry]);
|
||||
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
|
||||
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
|
||||
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
|
||||
</LoadingRegion>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { overviewChartPoints } from "../../core/observation/sessionOverview";
|
||||
|
||||
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
|
||||
const svg = useRef<SVGSVGElement>(null);
|
||||
const [size, setSize] = useState({ width: 900, height: 160 });
|
||||
useEffect(() => {
|
||||
if (!svg.current) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||||
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
}
|
||||
});
|
||||
observer.observe(svg.current);
|
||||
return () => observer.disconnect();
|
||||
}, [chart.length > 0]);
|
||||
const width = Math.max(1, size.width - 106);
|
||||
const height = Math.max(1, size.height - 38);
|
||||
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
|
||||
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
|
||||
return <div className="session-overview__chart">
|
||||
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
|
||||
<g transform="translate(64,10)">
|
||||
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
|
||||
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
|
||||
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
|
||||
</g>)}
|
||||
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
|
||||
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
|
||||
</g>
|
||||
</svg>
|
||||
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
|
||||
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
|
||||
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
|
||||
|
||||
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
|
||||
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
|
||||
const [ceiling, setCeiling] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<OverviewViewMode>("3d");
|
||||
const [viewError, setViewError] = useState<string | null>(null);
|
||||
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
|
||||
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const comparison = metadata?.comparison;
|
||||
const appliedMode = useRef<OverviewViewMode | null>(null);
|
||||
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
let disposed = false;
|
||||
const abort = new AbortController();
|
||||
appliedMode.current = null;
|
||||
setState("loading");
|
||||
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
|
||||
setRepresentation("original"); setAppliedRepresentation("original"); setUpdating(false);
|
||||
void fetchOverviewSpatial(sourceUrl, abort.signal).then(data => {
|
||||
if (disposed) return;
|
||||
setRepresentation(data.default_representation ?? "original"); setMetadata(data);
|
||||
}).catch(() => { if (!disposed) { setState("error"); setViewError("Версия облака недоступна."); } });
|
||||
const runtime = createIsolatedRerunHost(host.current);
|
||||
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||
if (disposed) return;
|
||||
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||
width: "100%", height: "100%", hide_welcome_screen: true,
|
||||
enable_history: false, allow_fullscreen: false,
|
||||
});
|
||||
if (disposed) return;
|
||||
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
|
||||
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
|
||||
}, [sourceUrl, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !metadata || !controller.current) return;
|
||||
const abort = new AbortController();
|
||||
setUpdating(true);
|
||||
const timer = setTimeout(() => {
|
||||
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
|
||||
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal,
|
||||
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
|
||||
if (abort.signal.aborted || !controller.current) return;
|
||||
controller.current.channel.send_rrd(result.bytes);
|
||||
appliedMode.current = mode;
|
||||
setVisiblePoints(result.visiblePoints); setViewError(null);
|
||||
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
|
||||
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
|
||||
}, 180);
|
||||
return () => { abort.abort(); clearTimeout(timer); };
|
||||
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
|
||||
|
||||
const low = comparison?.height_min_m ?? metadata?.height_min_m;
|
||||
const high = comparison?.height_max_m ?? metadata?.height_max_m;
|
||||
return <>
|
||||
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
|
||||
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
|
||||
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
|
||||
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
|
||||
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
|
||||
{toolbar}
|
||||
</div>
|
||||
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
|
||||
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
|
||||
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
|
||||
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
|
||||
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
|
||||
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
|
||||
</div>}
|
||||
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</LoadingRegion>
|
||||
<span className="session-overview__note" role="status" aria-busy={updating}>
|
||||
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
|
||||
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
|
||||
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
|
||||
</span>
|
||||
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,244 +1,28 @@
|
||||
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
|
||||
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
|
||||
export type RecordedRerunCameraEye = {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly lookTarget: readonly [number, number, number];
|
||||
readonly eyeUp: readonly [number, number, number];
|
||||
};
|
||||
|
||||
type Vector3 = [number, number, number];
|
||||
type DragMode = "rotate" | "pan" | "roll";
|
||||
|
||||
const ROTATION_RADIANS_PER_POINT = 0.004;
|
||||
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
|
||||
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
|
||||
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
|
||||
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
|
||||
const DOM_WHEEL_DELTA_LINE = 1;
|
||||
const DOM_WHEEL_DELTA_PAGE = 2;
|
||||
|
||||
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||
position: [16, -16, 18],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 0, 1],
|
||||
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
|
||||
};
|
||||
|
||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||
position: [0, 0, 30],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 1, 0],
|
||||
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
|
||||
};
|
||||
|
||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
const length = (value: Vector3) => Math.hypot(...value);
|
||||
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
|
||||
const magnitude = length(value);
|
||||
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
|
||||
};
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
|
||||
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
|
||||
const axis = normalize(rawAxis, [0, 0, 1]);
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
return add(
|
||||
add(scale(value, cosine), scale(cross(axis, value), sine)),
|
||||
scale(axis, dot(axis, value) * (1 - cosine)),
|
||||
);
|
||||
};
|
||||
|
||||
function cloneEye(value: RecordedRerunCameraEye): {
|
||||
position: Vector3;
|
||||
lookTarget: Vector3;
|
||||
eyeUp: Vector3;
|
||||
} {
|
||||
return {
|
||||
position: vector(value.position),
|
||||
lookTarget: vector(value.lookTarget),
|
||||
eyeUp: vector(value.eyeUp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
|
||||
* renderer. Rerun writes operator navigation into its active blueprint clone;
|
||||
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
|
||||
* here lets a later layer blueprint activate with the exact operator pose.
|
||||
*/
|
||||
export function createRecordedRerunCameraJournal(
|
||||
canvas: HTMLCanvasElement,
|
||||
scope: Window & typeof globalThis,
|
||||
) {
|
||||
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
|
||||
let spatialViewportStart = 0;
|
||||
let pointerId: number | null = null;
|
||||
let dragMode: DragMode | null = null;
|
||||
let previousPointer: readonly [number, number] | null = null;
|
||||
let latestPointer: readonly [number, number] | null = null;
|
||||
let maxOrbitalRadius = 1.0e17;
|
||||
|
||||
const isSpatialPoint = (event: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
|
||||
};
|
||||
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
|
||||
const usableUp = () => {
|
||||
const fwd = forward();
|
||||
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
|
||||
? [1, 0, 0]
|
||||
: cross(fwd, [0, 0, 1]);
|
||||
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
|
||||
const candidate = normalize(eye.eyeUp, fallback);
|
||||
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
|
||||
};
|
||||
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
|
||||
|
||||
const rotate = (deltaX: number, deltaY: number) => {
|
||||
const radius = orbitRadius();
|
||||
const up = usableUp();
|
||||
let fwd = forward();
|
||||
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
|
||||
fwd = normalize(
|
||||
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
|
||||
fwd,
|
||||
);
|
||||
const right = normalize(cross(fwd, up), [1, 0, 0]);
|
||||
const maxPitch = 0.99 * Math.PI / 2;
|
||||
const nextPitch = clamp(
|
||||
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
|
||||
-maxPitch,
|
||||
maxPitch,
|
||||
);
|
||||
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
|
||||
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
|
||||
};
|
||||
|
||||
const pan = (deltaX: number, deltaY: number) => {
|
||||
const fwd = forward();
|
||||
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
|
||||
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
|
||||
const speed = 0.001 * orbitRadius();
|
||||
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
|
||||
eye.position = add(eye.position, translation);
|
||||
eye.lookTarget = add(eye.lookTarget, translation);
|
||||
};
|
||||
|
||||
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const left = rect.left + rect.width * spatialViewportStart;
|
||||
const centerX = left + (rect.right - left) / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const relativeX = event.clientX - centerX;
|
||||
const relativeY = event.clientY - centerY;
|
||||
const divisor = relativeX * relativeX + relativeY * relativeY;
|
||||
if (divisor <= Number.EPSILON) return;
|
||||
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
|
||||
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
|
||||
};
|
||||
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (!isSpatialPoint(event)) return;
|
||||
pointerId = event.pointerId;
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
|
||||
const deltaX = event.clientX - previousPointer[0];
|
||||
const deltaY = event.clientY - previousPointer[1];
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
if (dragMode === "rotate") rotate(deltaX, deltaY);
|
||||
else if (dragMode === "pan") pan(deltaX, deltaY);
|
||||
else roll(event, deltaX, deltaY);
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerMove(event);
|
||||
pointerId = null;
|
||||
previousPointer = null;
|
||||
dragMode = null;
|
||||
};
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
|
||||
? event.clientX
|
||||
: latestPointer?.[0];
|
||||
if (wheelX === undefined || rect.width <= 0) return;
|
||||
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
|
||||
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
|
||||
? RERUN_WEB_LINE_SCROLL_POINTS
|
||||
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
|
||||
? rect.height
|
||||
: 1;
|
||||
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
|
||||
// eframe negates the DOM delta before Rerun applies
|
||||
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
|
||||
// exponent products collapse to the raw accumulated DOM delta below.
|
||||
const divisor = event.ctrlKey
|
||||
? RERUN_WEB_PINCH_SCROLL_DIVISOR
|
||||
: RERUN_ORBITAL_SCROLL_DIVISOR;
|
||||
const radiusFactor = Math.exp(scrollPoints / divisor);
|
||||
const offset = subtract(eye.position, eye.lookTarget);
|
||||
const radius = length(offset);
|
||||
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
|
||||
// the scene-derived cap is allowed to stay there and is never snapped in.
|
||||
const nextRadius = clamp(
|
||||
radius * radiusFactor,
|
||||
RERUN_MIN_ORBIT_DISTANCE,
|
||||
Math.max(radius, maxOrbitalRadius),
|
||||
);
|
||||
eye.position = add(
|
||||
eye.lookTarget,
|
||||
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", pointerDown, true);
|
||||
scope.addEventListener("pointermove", pointerMove, true);
|
||||
scope.addEventListener("pointerup", pointerEnd, true);
|
||||
scope.addEventListener("pointercancel", pointerEnd, true);
|
||||
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
|
||||
|
||||
return {
|
||||
current(): RecordedRerunCameraEye {
|
||||
return {
|
||||
position: [...eye.position],
|
||||
lookTarget: [...eye.lookTarget],
|
||||
eyeUp: [...eye.eyeUp],
|
||||
};
|
||||
},
|
||||
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
|
||||
eye = cloneEye(nextEye);
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
pointerId = null;
|
||||
dragMode = null;
|
||||
previousPointer = null;
|
||||
},
|
||||
setSpatialViewportStart(nextSpatialViewportStart: number) {
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
},
|
||||
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
|
||||
if (
|
||||
Number.isFinite(nextMaxOrbitalRadius) &&
|
||||
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
|
||||
) {
|
||||
maxOrbitalRadius = nextMaxOrbitalRadius;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointerdown", pointerDown, true);
|
||||
scope.removeEventListener("pointermove", pointerMove, true);
|
||||
scope.removeEventListener("pointerup", pointerEnd, true);
|
||||
scope.removeEventListener("pointercancel", pointerEnd, true);
|
||||
scope.removeEventListener("wheel", wheel, true);
|
||||
},
|
||||
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
|
||||
if (value == null) return null; // No 3D frame rendered yet.
|
||||
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
|
||||
const eye = value as Record<string, unknown>;
|
||||
const vector = (key: string): [number, number, number] => {
|
||||
const item = eye[key];
|
||||
if (!Array.isArray(item) || item.length !== 3 ||
|
||||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
|
||||
throw new Error("Invalid native Rerun camera vector");
|
||||
}
|
||||
return [item[0], item[1], item[2]];
|
||||
};
|
||||
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ export type RecordedRerunViewer = Pick<WebViewer,
|
||||
"set_current_time" | "set_playing"
|
||||
> & {
|
||||
open_channel: (name?: string) => Channel;
|
||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye;
|
||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye | null;
|
||||
};
|
||||
|
||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
||||
@@ -90,16 +87,7 @@ export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
|
||||
set_active_timeline: (...args) => command("set_active_timeline", args),
|
||||
set_current_time: (...args) => command("set_current_time", args),
|
||||
set_playing: (...args) => command("set_playing", args),
|
||||
configure_camera_journal: (eye, spatialViewportStart) => command(
|
||||
"configure-camera-journal", [eye, spatialViewportStart],
|
||||
),
|
||||
get_camera_eye: () => command("get-camera-eye"),
|
||||
set_camera_viewport_start: (spatialViewportStart) => command(
|
||||
"set-camera-viewport-start", [spatialViewportStart],
|
||||
),
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
|
||||
"set-camera-max-orbital-radius", [maxOrbitalRadius],
|
||||
),
|
||||
};
|
||||
return { facade, dispose, notify };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
|
||||
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||
|
||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||
@@ -8,7 +8,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
let notify: RerunNotify | null = null;
|
||||
const subscriptions = new Map<number, () => void>();
|
||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
||||
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
|
||||
const send = (message: object) => notify?.(JSON.stringify(message));
|
||||
const stop = () => {
|
||||
notify = null;
|
||||
@@ -20,8 +19,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||
}
|
||||
channels.clear();
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = null;
|
||||
const current = native;
|
||||
native = null;
|
||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||
@@ -34,13 +31,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
const starting = required();
|
||||
try {
|
||||
await starting.start(args[0], mount, args[1]);
|
||||
if (native === starting && starting.canvas) {
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = createRecordedRerunCameraJournal(
|
||||
starting.canvas,
|
||||
window as Window & typeof globalThis,
|
||||
);
|
||||
}
|
||||
if (native === starting) send({ type: "started", id });
|
||||
} catch (error) {
|
||||
if (native === starting) send({ type: "start-failed", id, message: String(error) });
|
||||
@@ -87,10 +77,9 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
channels.delete(id);
|
||||
}
|
||||
break;
|
||||
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
|
||||
case "get-camera-eye": result = readNativeRerunCameraEye(
|
||||
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
|
||||
); break;
|
||||
case "open": required().open(args[0]); break;
|
||||
case "close": required().close(args[0]); break;
|
||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
|
||||
import type {RecordedPointColorLoadState} from '../RerunViewport';
|
||||
|
||||
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
|
||||
|
||||
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
|
||||
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
|
||||
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
|
||||
sourceGeneration?: string;
|
||||
}) {
|
||||
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
|
||||
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
|
||||
const loadRef = useRef(onLoad); loadRef.current = onLoad;
|
||||
const failed = useRef(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const key = settings ? pointDisplayKey(settings) : '';
|
||||
const percent = settings?.pointDecimationPercent ?? 0;
|
||||
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
|
||||
const matched = sameSource && applied?.key === key;
|
||||
useEffect(() => {
|
||||
// A new settings interaction retries a failed request, but accumulation or
|
||||
// size edits must not cancel an already-running preparation of the same key.
|
||||
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
|
||||
}, [settings]);
|
||||
useEffect(() => {
|
||||
if (!endpoint || !settings || !ready) return;
|
||||
if (percent === 0 || percent === 100) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
|
||||
return;
|
||||
}
|
||||
if (matched) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: 'Прореженное облако готово.'});
|
||||
return;
|
||||
}
|
||||
const owner = ownerRef.current();
|
||||
if (!owner) return;
|
||||
const abort = new AbortController();
|
||||
failed.current = false;
|
||||
// Never append another generation at the same temporal entity path: an
|
||||
// accumulated range would otherwise draw older samples as well.
|
||||
const bank = crypto.randomUUID().replaceAll('-', '');
|
||||
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
|
||||
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
|
||||
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
|
||||
if (!abort.signal.aborted) owner.send(chunk);
|
||||
}, undefined, sourceGeneration).then(bytes => {
|
||||
if (abort.signal.aborted) return;
|
||||
setApplied({endpoint, revision, key, bank, sourceGeneration});
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
|
||||
message: 'Прореженное облако готово.'});
|
||||
}).catch(() => {
|
||||
if (!abort.signal.aborted) {
|
||||
failed.current = true;
|
||||
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
|
||||
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
|
||||
}
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
|
||||
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
|
||||
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
|
||||
hidePoints: percent === 100};
|
||||
}
|
||||
@@ -6,19 +6,18 @@ interface TelemetrySeriesProps {
|
||||
ceiling?: number;
|
||||
}
|
||||
|
||||
function linePoints(values: Array<number | null>, ceiling?: number): string {
|
||||
const finite = values.filter((value): value is number =>
|
||||
value !== null && Number.isFinite(value),
|
||||
);
|
||||
if (finite.length === 0) return "";
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1);
|
||||
const denominator = Math.max(1, values.length - 1);
|
||||
return values.map((value, index) => {
|
||||
const x = index / denominator * 100;
|
||||
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value));
|
||||
const y = 36 - normalized / maximum * 34;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(" ");
|
||||
export function lineSegments(values: Array<number | null>, ceiling?: number): string[] {
|
||||
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
if (!finite.length) return [];
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1), denominator = Math.max(1, values.length - 1);
|
||||
const minimum = Math.min(0, ...finite), span = maximum - minimum;
|
||||
const segments:string[]=[];let segment:string[]=[];
|
||||
values.forEach((value,index)=>{
|
||||
if(value===null||!Number.isFinite(value)){if(segment.length)segments.push(segment.join(' '));segment=[];return;}
|
||||
segment.push(`${(index/denominator*100).toFixed(2)},${(36-(Math.min(maximum,value)-minimum)/span*34).toFixed(2)}`);
|
||||
});
|
||||
if(segment.length)segments.push(segment.join(' '));
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function TelemetrySeries({
|
||||
@@ -28,7 +27,7 @@ export function TelemetrySeries({
|
||||
resource,
|
||||
ceiling,
|
||||
}: TelemetrySeriesProps) {
|
||||
const points = linePoints(values, ceiling);
|
||||
const segments = lineSegments(values, ceiling);
|
||||
return (
|
||||
<div className="system-telemetry-series">
|
||||
<div>
|
||||
@@ -45,7 +44,7 @@ export function TelemetrySeries({
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
<path d="M0 37 H100" />
|
||||
{points ? <polyline points={points} /> : null}
|
||||
{segments.map((points,index)=><polyline key={index} points={points} />)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ApplicationPanelActionsOptions {
|
||||
saveWorkspaceLayout: () => Promise<void>;
|
||||
workspaceLayoutSaving: boolean;
|
||||
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
|
||||
}
|
||||
|
||||
export function deviceRuntimeUtilityAction({
|
||||
@@ -51,6 +52,7 @@ export function useApplicationPanelActions({
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving,
|
||||
systemUtilityActions,
|
||||
sessionOverview,
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
@@ -71,6 +73,11 @@ export function useApplicationPanelActions({
|
||||
});
|
||||
}
|
||||
if (definition?.root === "system") actions.push(...systemUtilityActions);
|
||||
if (definition?.kind === "recordings" && sessionOverview) actions.push({
|
||||
label: sessionOverview.open ? "Закрыть информацию о записи" : "Информация о записи",
|
||||
icon: "info", pressed: sessionOverview.open, disabled: !sessionOverview.available,
|
||||
onClick: sessionOverview.toggle,
|
||||
});
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
@@ -81,5 +88,6 @@ export function useApplicationPanelActions({
|
||||
saveWorkspaceLayout,
|
||||
systemUtilityActions,
|
||||
workspaceLayoutSaving,
|
||||
sessionOverview,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
|
||||
// Composition root: this is the only place where Mission Core chooses which
|
||||
// statically reviewed device plugins are shipped in the current build.
|
||||
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||
xgridsK1Plugin,
|
||||
]);
|
||||
|
||||
// Node-executed camera controls use the existing sensor contribution contract;
|
||||
// they do not register a desktop capture/AI runtime or a new product workspace.
|
||||
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
||||
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
||||
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
||||
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||
const NO_NODE_SENSORS: readonly SensorUiContribution[] = Object.freeze([]);
|
||||
|
||||
interface DevicePluginHostValue {
|
||||
registry: DevicePluginRegistry;
|
||||
@@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId(
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
nodeSensorContributions = NO_NODE_SENSORS,
|
||||
children,
|
||||
}: {
|
||||
plugins: readonly DeviceUiPlugin[];
|
||||
nodeSensorContributions?: readonly SensorUiContribution[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]);
|
||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
|
||||
@@ -97,12 +97,24 @@ export interface DevicePluginHostActions {
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
/** Session-bound, presentation-only extension of an active spatial workflow. */
|
||||
export interface SpatialActivityPresentation {
|
||||
sessionId: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
model: DeviceModelDefinition;
|
||||
host: DevicePluginHostActions;
|
||||
spatialActivity?: SpatialActivityPresentation;
|
||||
}
|
||||
|
||||
export interface DeviceUiPlugin {
|
||||
sensorUi?: {
|
||||
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
|
||||
};
|
||||
manifest: DevicePluginManifest;
|
||||
RuntimeProvider: ComponentType<{
|
||||
activeModel: DeviceModelDefinition | null;
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
type DeviceUiPlugin,
|
||||
type RegisteredDeviceModel,
|
||||
} from "./contracts";
|
||||
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||
|
||||
export interface DevicePluginRegistry {
|
||||
readonly sensorContributions: readonly SensorUiContribution[];
|
||||
readonly plugins: readonly DeviceUiPlugin[];
|
||||
readonly models: readonly RegisteredDeviceModel[];
|
||||
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
||||
@@ -15,6 +17,7 @@ export interface DevicePluginRegistry {
|
||||
|
||||
export function createDevicePluginRegistry(
|
||||
installedPlugins: readonly DeviceUiPlugin[],
|
||||
nodeSensorContributions: readonly SensorUiContribution[] = [],
|
||||
): DevicePluginRegistry {
|
||||
const pluginIds = new Set<string>();
|
||||
const modelIds = new Set<string>();
|
||||
@@ -133,7 +136,15 @@ export function createDevicePluginRegistry(
|
||||
}
|
||||
|
||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||
const sensorContributions = [
|
||||
...installedPlugins.flatMap(plugin => plugin.sensorUi?.contributions ?? []),
|
||||
...nodeSensorContributions,
|
||||
];
|
||||
if (new Set(sensorContributions.map(value => value.kind)).size !== sensorContributions.length) {
|
||||
throw new Error('Плагины повторяют модель устройства БК.');
|
||||
}
|
||||
return {
|
||||
sensorContributions: Object.freeze(sensorContributions),
|
||||
plugins: Object.freeze([...installedPlugins]),
|
||||
models: Object.freeze(models),
|
||||
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
import { roots, type RootId } from "../../productModel";
|
||||
|
||||
export type EnvironmentSurfaceId = "home" | RootId;
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentMediaItem {
|
||||
id: string;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: EnvironmentMediaItem[];
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
import { defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems, type EnvironmentBackground, type EnvironmentMediaItem, type EnvironmentMediaKind, type EnvironmentMediaSource, type EnvironmentPage } from "@nodedc/ui-core";
|
||||
export { createEnvironmentMediaItem, appendEnvironmentMediaItem, removeEnvironmentMediaItem, inferEnvironmentMediaKind, defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems } from "@nodedc/ui-core";
|
||||
export type { EnvironmentBackground, EnvironmentMediaItem, EnvironmentMediaKind, EnvironmentMediaSource, EnvironmentPage } from "@nodedc/ui-core";
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
revision: number;
|
||||
@@ -54,72 +31,19 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
||||
"polygon",
|
||||
];
|
||||
|
||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
||||
export const maxEnvironmentMediaItems = 24;
|
||||
|
||||
function emptyBackground(): EnvironmentBackground {
|
||||
return {
|
||||
enabled: false,
|
||||
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
||||
return {
|
||||
id: `media-${crypto.randomUUID()}`,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
||||
): EnvironmentBackground {
|
||||
if (
|
||||
background.items.length >= maxEnvironmentMediaItems
|
||||
|| background.items.some((candidate) => candidate.id === item.id)
|
||||
) {
|
||||
return background;
|
||||
}
|
||||
return {
|
||||
...background,
|
||||
enabled: background.items.length === 0 ? true : background.enabled,
|
||||
items: [...background.items, item],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
): EnvironmentBackground {
|
||||
const items = background.items.filter((item) => item.id !== itemId);
|
||||
if (items.length === background.items.length) return background;
|
||||
return {
|
||||
...background,
|
||||
enabled: items.length > 0 && background.enabled,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
function emptyBackground(): EnvironmentBackground { return { enabled: false, imageDurationSeconds: defaultEnvironmentImageDurationSeconds, items: [] }; }
|
||||
|
||||
const defaultQuickActions: Record<
|
||||
EnvironmentSurfaceId,
|
||||
readonly [string | null, string | null]
|
||||
> = {
|
||||
home: ["spatial-scene", "local-device"],
|
||||
fleet: ["contour-health", "local-device"],
|
||||
observation: ["spatial-scene", "cameras"],
|
||||
missions: ["mission-planner", null],
|
||||
home: ["spatial-scene", "vehicles"],
|
||||
fleet: ["vehicles", "contour-health"],
|
||||
observation: ["cameras", "world-map"],
|
||||
missions: ["routes", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", null],
|
||||
polygon: ["lab-archive", "local-device"],
|
||||
};
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
|
||||
export interface MetricDefinition {label:string;group:string;resource:string;unit:string;reason:string|null}
|
||||
export interface BoardMonitor {
|
||||
available:boolean;fresh:boolean;storage:string;source_id:string;
|
||||
latest:{seq:number;at:number;boot_id:string;values:Record<string,number|null>}|null;
|
||||
definitions:Record<string,MetricDefinition>;received_at:number;backlog:number;
|
||||
database_bytes:number|null;database_budget_bytes:number;archive_since:number|null;
|
||||
start:number;end:number;bucket_seconds:number;
|
||||
series:{at:number;min:number|null;max:number|null;mean:number|null;count:number}[];
|
||||
events:{at:number;code:string;kind:string;locations:string[]}[];
|
||||
}
|
||||
export function useBoardMonitor(vehicle:string,metric:string,window:number,end:number|null){
|
||||
const [value,setValue]=useState<BoardMonitor|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{
|
||||
let active=true;let pending:AbortController|undefined;let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
setValue(null);setError('');
|
||||
const read=async()=>{
|
||||
const controller=new AbortController();pending=controller;
|
||||
const deadline=setTimeout(()=>controller.abort(),12000);
|
||||
try {
|
||||
const query=new URLSearchParams({metric,window:String(window),...(end===null?{}:{end:String(end)})});
|
||||
const response=await fetch(`/api/v1/fleet/${encodeURIComponent(vehicle)}/monitor?${query}`,{credentials:'same-origin',cache:'no-store',signal:controller.signal});
|
||||
if(!response.ok)throw new Error('Мониторинг БК недоступен.');
|
||||
const next=await response.json() as BoardMonitor;
|
||||
if(active){setValue(next);setError('');}
|
||||
}catch {if(active)setError('Не удалось обновить мониторинг БК.');}
|
||||
finally {clearTimeout(deadline);if(active)timer=setTimeout(()=>void read(),5000);}
|
||||
};
|
||||
void read();return()=>{active=false;pending?.abort();clearTimeout(timer);};
|
||||
},[vehicle,metric,window,end]);
|
||||
return {value,error,dismissError:()=>setError('')};
|
||||
}
|
||||
export function monitorSeries(value:BoardMonitor,mode:'min'|'max'|'mean') {
|
||||
const count=Math.ceil((value.end-value.start)/value.bucket_seconds)+1;
|
||||
const values:Array<number|null>=Array(Math.min(count,302)).fill(null);
|
||||
const first=Math.floor(value.start/value.bucket_seconds)*value.bucket_seconds;
|
||||
for(const point of value.series){const index=Math.round((point.at-first)/value.bucket_seconds);if(index>=0&&index<values.length)values[index]=point[mode];}
|
||||
return values;
|
||||
}
|
||||
export function monitorValue(value:number|null|undefined,unit=''){
|
||||
if(value==null||!Number.isFinite(value))return 'Нет измерения';
|
||||
if(unit==='bytes'||unit==='bytes/s'){
|
||||
const labels=unit==='bytes'?['Б','КиБ','МиБ','ГиБ']:['Б/с','КиБ/с','МиБ/с','ГиБ/с'];let index=0;
|
||||
while(value>=1024&&index<3){value/=1024;index++;}
|
||||
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})} ${labels[index]}`;
|
||||
}
|
||||
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})}${unit?' '+unit:''}`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface ObservationLayout {
|
||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
}
|
||||
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
||||
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
||||
export function decodeObservationLayout(value:unknown):ObservationLayout {
|
||||
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
|
||||
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
|
||||
result.order=ids(source.order);result.hidden=ids(source.hidden);
|
||||
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
|
||||
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
|
||||
if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement;
|
||||
return result;
|
||||
}
|
||||
export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{
|
||||
const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))];
|
||||
}
|
||||
export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout {
|
||||
const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to);
|
||||
if(source<0||target<0||source===target)return layout;
|
||||
order.splice(source,1);order.splice(target,0,from);return {...layout,order};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from './useFleet';
|
||||
|
||||
export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||
const path=`/${encodeURIComponent(vehicleID)}/devices`;
|
||||
const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{
|
||||
const value=fleet.items.find(v=>v.id===vehicleID);
|
||||
if(!value)throw new Error('Аппарат не найден.');
|
||||
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
|
||||
};
|
||||
return {
|
||||
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
|
||||
inventory:async()=>inventory(await fleetRequest()),
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`${path}/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { plannerBase, plannerRequest, type Draft } from './planner';
|
||||
|
||||
export interface PlanningLiveTest {
|
||||
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
|
||||
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
|
||||
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
|
||||
tracking_state?: 'acquiring'|'tracking'|'lost';
|
||||
tracking_established?: boolean;
|
||||
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
|
||||
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
|
||||
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
|
||||
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
|
||||
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
|
||||
route_relocalization_policy?: {version?:string;query_radius_m?:number};
|
||||
}
|
||||
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
|
||||
const endpoint = plannerBase+'/live-tests';
|
||||
const terminal = new Set(['completed','cancelled','error','interrupted']);
|
||||
const Context = createContext<{
|
||||
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
|
||||
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
|
||||
}|null>(null);
|
||||
|
||||
/** Server owns the run and its frozen reference, never the workspace launch profile. */
|
||||
export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||
const [test,setTest]=useState<PlanningLiveTest|null>(null);
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
|
||||
const [pollError,setPollError]=useState<string|null>(null);
|
||||
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
|
||||
const generation=useRef(0);
|
||||
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
|
||||
const select=useCallback(async(id:string)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
let disposed=false, timer:ReturnType<typeof setTimeout>;
|
||||
const poll=async()=>{
|
||||
const expected=generation.current;
|
||||
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
|
||||
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
|
||||
if(!disposed)timer=setTimeout(()=>void poll(),1500);
|
||||
};void poll();return()=>{disposed=true;clearTimeout(timer);};
|
||||
},[]);
|
||||
const begin=useCallback(async(draft:Draft)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
const finish=useCallback(async()=>{
|
||||
if(!test)return;generation.current+=1;setBusy(true);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[test]);
|
||||
const retryInitialization=useCallback(async()=>{
|
||||
if(!test)return;generation.current+=1;setBusy(true);setError(null);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[test]);
|
||||
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
|
||||
}
|
||||
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
|
||||
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
|
||||
|
||||
/** A prepared consumer owns the next capture until explicitly finished or used. */
|
||||
export function planningAwaitsCapture(test:PlanningLiveTest|null){
|
||||
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
|
||||
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
|
||||
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
|
||||
export type Direction = "forward" | "reverse";
|
||||
export interface Draft {
|
||||
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
|
||||
zone: { session_id: string; generation: string; label: string };
|
||||
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
|
||||
}
|
||||
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
|
||||
export const plannerBase = "/api/v1/mission-planner";
|
||||
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
|
||||
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
|
||||
}
|
||||
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
|
||||
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
|
||||
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
|
||||
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
|
||||
const poses = source.poses.slice(start, end + 1);
|
||||
return direction === "reverse" ? poses.reverse() : poses;
|
||||
}
|
||||
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
|
||||
export function endAtDistance(source: PlanningSource, start: number, length: number) {
|
||||
const target = source.poses[start].distance_m + length;
|
||||
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
|
||||
return index < 0 ? source.poses.length - 1 : index;
|
||||
}
|
||||
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
|
||||
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|
||||
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|
||||
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
|
||||
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
|
||||
let low = min, high = max;
|
||||
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
|
||||
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
|
||||
|
||||
export interface PlanningMatchState {
|
||||
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
|
||||
result:{status:string}|null;
|
||||
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
|
||||
presentation_state?:'live'|'historical'|'unlocalized';
|
||||
}
|
||||
/** Current geometry is insufficient: the temporal gate must also be tracking. */
|
||||
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
|
||||
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
|
||||
&&(t.presentation_state===undefined||t.presentation_state==='live')
|
||||
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
|
||||
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
|
||||
&&t.result?.status==='candidate';
|
||||
}
|
||||
|
||||
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
||||
type PlanningPresentation=PlanningMatchState & {message:string};
|
||||
const phaseLabels:Record<string,string>={
|
||||
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
|
||||
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
|
||||
};
|
||||
|
||||
/** Terminal failures and data freshness take precedence over successful old fits. */
|
||||
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
|
||||
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
|
||||
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
|
||||
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
|
||||
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
|
||||
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
|
||||
if(t.planning_phase==='lost'&&!t.tracking_established)
|
||||
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
|
||||
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
|
||||
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
|
||||
if(t.planning_phase&&phaseLabels[t.planning_phase])
|
||||
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
|
||||
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
|
||||
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
|
||||
}
|
||||
|
||||
/** A plugin may show this only for its matching, authoritative acquiring session. */
|
||||
export function planningActivity(
|
||||
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
|
||||
):SpatialActivityPresentation|undefined{
|
||||
if(!t.query_session_id||!['running','error'].includes(t.state)
|
||||
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
|
||||
const status=planningStatus(t,error);
|
||||
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
|
||||
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PlanningSceneDelivery } from './planningSceneStream';
|
||||
|
||||
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
|
||||
'missioncore.planning-browser-presentation/v1';
|
||||
|
||||
type BrowserPresentationSample = {
|
||||
cloud_revision:number;
|
||||
cloud_sequence:number;
|
||||
display_epoch:string;
|
||||
request_ms:number;
|
||||
rerun_admission_ms:number;
|
||||
first_animation_frame_ms:number|null;
|
||||
second_animation_frame_ms:number|null;
|
||||
frame_timeout:boolean;
|
||||
source_to_second_animation_frame_upper_bound_ms:number|null;
|
||||
};
|
||||
|
||||
type ReporterDependencies = {
|
||||
url:string;
|
||||
fetcher?:typeof fetch;
|
||||
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||
};
|
||||
|
||||
const batchSize=8;
|
||||
const flushDelayMs=250;
|
||||
|
||||
/**
|
||||
* Bounded, best-effort browser observation. These samples never drive scene
|
||||
* admission, registration, navigation, or device control.
|
||||
*/
|
||||
export function createPlanningPresentationReporter(deps:ReporterDependencies){
|
||||
const fetcher=deps.fetcher??fetch;
|
||||
const schedule=deps.schedule??setTimeout;
|
||||
const cancel=deps.cancel??clearTimeout;
|
||||
let pending:BrowserPresentationSample[]=[];
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
let sending=false,stopped=false;
|
||||
|
||||
const flush=async()=>{
|
||||
if(sending||stopped||!pending.length)return;
|
||||
if(timer!==undefined){cancel(timer);timer=undefined;}
|
||||
sending=true;
|
||||
const samples=pending.splice(0,batchSize);
|
||||
try{
|
||||
// Deliberately best-effort: the presentation observer must not make the
|
||||
// live Rerun channel wait for an unrelated report request.
|
||||
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
|
||||
});
|
||||
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
|
||||
}catch{
|
||||
// A lost browser report is observable as fewer samples, never retried into
|
||||
// an unbounded queue and never promoted into a capture/fit failure.
|
||||
}finally{
|
||||
sending=false;
|
||||
if(!stopped&&pending.length)void flush();
|
||||
}
|
||||
};
|
||||
|
||||
const record=(delivery:PlanningSceneDelivery,timing:{
|
||||
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
|
||||
secondAnimationFrameMs:number|null;
|
||||
})=>{
|
||||
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
|
||||
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
|
||||
const second=timing.secondAnimationFrameMs;
|
||||
pending.push({
|
||||
cloud_revision:delivery.cloudRevision,
|
||||
cloud_sequence:delivery.cloudSequence,
|
||||
display_epoch:delivery.displayEpoch,
|
||||
request_ms:delivery.requestMs,
|
||||
rerun_admission_ms:timing.rerunAdmissionMs,
|
||||
first_animation_frame_ms:timing.firstAnimationFrameMs,
|
||||
second_animation_frame_ms:second,
|
||||
frame_timeout:second===null,
|
||||
source_to_second_animation_frame_upper_bound_ms:second===null?null:
|
||||
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
|
||||
});
|
||||
if(pending.length>=batchSize)void flush();
|
||||
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
|
||||
};
|
||||
|
||||
return {
|
||||
record,
|
||||
dispose:()=>{
|
||||
stopped=true;
|
||||
if(timer!==undefined)cancel(timer);
|
||||
pending=[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Draft } from './planner';
|
||||
import type { RegistrationReport } from './useRegistrationTest';
|
||||
import { plannerBase, plannerRequest } from './planner';
|
||||
|
||||
export interface PlanningProject {
|
||||
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
|
||||
state: string; created_at_utc: string; result_status: string|null;
|
||||
reference_label: string; query_label: string|null; draft_id: string; revision: number;
|
||||
}
|
||||
export interface PlanningProjectDetail extends PlanningProject {
|
||||
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
|
||||
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
|
||||
scene_note?:string|null;
|
||||
}
|
||||
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
|
||||
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|
||||
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
|
||||
export async function deletePlanningProject(project: PlanningProject) {
|
||||
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
|
||||
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
|
||||
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
|
||||
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
|
||||
);
|
||||
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
|
||||
}
|
||||
export function planningProjectStatus(p: PlanningProject) {
|
||||
if (p.kind === 'draft') return 'Подготовка';
|
||||
if (planningProjectPending(p)) return 'Выполняется';
|
||||
if (p.result_status === 'candidate') return 'Кандидат совмещения';
|
||||
if (p.result_status === 'rejected') return 'Совмещение отклонено';
|
||||
return 'Без результата совмещения';
|
||||
}
|
||||
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
|
||||
return items.find(p => p.key === last)?.key
|
||||
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
|
||||
?? items[0]?.key ?? '';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
|
||||
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
|
||||
export type PlanningSceneDelivery = {
|
||||
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
|
||||
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
|
||||
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
|
||||
};
|
||||
type Dependencies = {
|
||||
url:string; snapshot:()=>Snapshot;
|
||||
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
|
||||
fetcher?:typeof fetch; now?:()=>number;
|
||||
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||
};
|
||||
|
||||
export function startPlanningSceneStream(deps:Dependencies){
|
||||
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
|
||||
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
|
||||
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
let request:AbortController|undefined;
|
||||
const poll=async()=>{
|
||||
if(stopped)return;
|
||||
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
|
||||
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
|
||||
timer=schedule(()=>void poll(),500);return;
|
||||
}
|
||||
request=new AbortController();
|
||||
const deadline=schedule(()=>request?.abort(),2500);
|
||||
let failed=false;
|
||||
try{
|
||||
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
|
||||
params.set('base',String(needsBase||!cursor||key!==nextKey));
|
||||
if(cursor)params.set('cursor',cursor);
|
||||
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
|
||||
if(!response.ok)throw new Error('scene unavailable');
|
||||
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
|
||||
if(stopped)return;
|
||||
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
|
||||
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
|
||||
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
|
||||
const elapsed=now()-started;
|
||||
if(presentation==='live'){
|
||||
// Include the entire request duration: a conservative upper bound, without
|
||||
// comparing clocks on different hosts. Late bytes must not revive green.
|
||||
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
|
||||
throw new Error('scene expired during delivery');
|
||||
}
|
||||
const latest=deps.snapshot();
|
||||
// Never apply a response from the previous display mode or live/ended state.
|
||||
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
|
||||
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
|
||||
if(!nextCursor)throw new Error('scene cursor missing');
|
||||
if(bytes)await deps.apply(bytes,{
|
||||
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
|
||||
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
|
||||
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
|
||||
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
|
||||
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
|
||||
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
|
||||
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
|
||||
}); // Native admission only; the caller may record a separate presentation proxy.
|
||||
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
|
||||
}catch{
|
||||
// Keep the last admitted cursor (including camera intent). A full geometry
|
||||
// repair after a missed/expired response is not permission to reset the eye.
|
||||
if(!stopped){failed=true;needsBase=true;deps.error();}
|
||||
}finally{
|
||||
cancel(deadline);request=undefined;
|
||||
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
|
||||
}
|
||||
|
||||
function parseNonnegativeInteger(value:string|null){
|
||||
if(value===null||!/^\d+$/.test(value))return null;
|
||||
const parsed=Number(value);
|
||||
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value:string|null){
|
||||
if(value===null||value.trim()==='')return null;
|
||||
const parsed=Number(value);
|
||||
return Number.isFinite(parsed)?parsed:null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
|
||||
|
||||
export function useMissionPlanner() {
|
||||
const [sessions, setSessions] = useState<SessionOption[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [saved, setSaved] = useState<Draft | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||
const [direction, setDirection] = useState<Direction>("forward");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sourceError, setSourceError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [catalogBusy, setCatalogBusy] = useState(true);
|
||||
const [loadVersion, setLoadVersion] = useState(0);
|
||||
const [sourceVersion, setSourceVersion] = useState(0);
|
||||
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
|
||||
const [check, setCheck] = useState<RouteCheck | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setCatalogBusy(true); setError(null);
|
||||
void Promise.all([
|
||||
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1", { signal: abort.signal }),
|
||||
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
|
||||
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
|
||||
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
|
||||
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
|
||||
return () => abort.abort();
|
||||
}, [loadVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setSourceError(null);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
|
||||
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
|
||||
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
|
||||
return () => abort.abort();
|
||||
}, [sessionId, sourceVersion, pinnedGeneration]);
|
||||
|
||||
const currentSource = source?.session_id === sessionId ? source : null;
|
||||
// New executions always own the complete reference. No independent interval
|
||||
// state can survive a source change or collapse on same-source reselection.
|
||||
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
|
||||
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
|
||||
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
|
||||
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|
||||
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
|
||||
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
|
||||
const chooseSource = (id: string) => { setPinnedGeneration(null); setSessionId(id); setSourceVersion(n => n + 1); setCheck(null); };
|
||||
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
|
||||
const openDraft = async (id: string) => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
|
||||
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
|
||||
setDirection(next.route.direction); setCheck(null);
|
||||
setSourceVersion(n => n + 1);
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const save = async () => {
|
||||
if (!ready || !currentSource) return;
|
||||
setBusy(true); setError(null); setCheck(null);
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
|
||||
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
|
||||
generation: currentSource.generation, whole_recording: true, direction,
|
||||
}) });
|
||||
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
|
||||
return next;
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const runCheck = async () => {
|
||||
if (!saved || dirty || !ready) return;
|
||||
setBusy(true); setError(null); setCheck(null);
|
||||
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
|
||||
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const loadMore = async () => {
|
||||
if (!cursor) return;
|
||||
setCatalogBusy(true);
|
||||
try {
|
||||
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
||||
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
|
||||
};
|
||||
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
|
||||
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
|
||||
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
|
||||
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
|
||||
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { plannerBase, plannerRequest } from './planner';
|
||||
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
|
||||
|
||||
const storageKey = 'missioncore.planning-project.selected.v1';
|
||||
export function usePlanningProjects() {
|
||||
const [items, setItems] = useState<PlanningProject[]>([]);
|
||||
const [key, setKey] = useState('');
|
||||
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
|
||||
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
|
||||
const [version, setVersion] = useState(0);
|
||||
const initialized = useRef(false);
|
||||
const selectedKey = useRef('');
|
||||
const removedKeys = useRef(new Set<string>());
|
||||
const removing = useRef(false);
|
||||
const select = useCallback((value: string) => {
|
||||
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
|
||||
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
|
||||
}, []);
|
||||
const refresh = useCallback(() => setVersion(n=>n+1), []);
|
||||
const remove = useCallback(async (project: PlanningProject) => {
|
||||
if (removing.current) throw new Error('Удаление уже выполняется.');
|
||||
removing.current = true;
|
||||
try {
|
||||
await deletePlanningProject(project);
|
||||
removedKeys.current.add(project.key);
|
||||
setItems(items => items.filter(item => item.key !== project.key));
|
||||
if (selectedKey.current === project.key) select('');
|
||||
} finally { removing.current = false; }
|
||||
}, [select]);
|
||||
useEffect(()=>{
|
||||
const abort = new AbortController(); setLoading(true); setError(null);
|
||||
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
|
||||
if (abort.signal.aborted) return;
|
||||
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
|
||||
setItems(visible);
|
||||
if (!initialized.current) {
|
||||
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
|
||||
select(defaultPlanningProject(visible,last));
|
||||
}
|
||||
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
|
||||
return ()=>abort.abort();
|
||||
},[version,select]);
|
||||
useEffect(()=>{
|
||||
if (!key) return;
|
||||
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
|
||||
setDetail(null); setError(null);
|
||||
const [kind,id]=key.split(':');
|
||||
const poll=async()=>{
|
||||
try {
|
||||
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
|
||||
if(abort.signal.aborted || removedKeys.current.has(key))return;
|
||||
if(next.key!==key)throw new Error('Получен результат другого исследования.');
|
||||
setDetail(next);setError(null);
|
||||
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
|
||||
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
|
||||
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
|
||||
};
|
||||
void poll(); return()=>{abort.abort();clearTimeout(timer);};
|
||||
},[key,version]);
|
||||
return {items,key,detail,loading,error,select,refresh,remove};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
|
||||
|
||||
export interface RegistrationReport {
|
||||
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
|
||||
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
|
||||
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
|
||||
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
|
||||
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
|
||||
}
|
||||
|
||||
export function useRegistrationTest(draft: Draft | null) {
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
|
||||
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
|
||||
const [catalogBusy,setCatalogBusy]=useState(false);
|
||||
// Repeat recordings belong to the planner, including its own captured passes;
|
||||
// they are not restricted to the independent-reference catalog.
|
||||
useEffect(()=>{
|
||||
const abort=new AbortController();setCatalogBusy(true);
|
||||
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
|
||||
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
|
||||
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
|
||||
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
|
||||
return()=>abort.abort();
|
||||
},[]);
|
||||
const loadMore=async()=>{
|
||||
if(!cursor||catalogBusy)return;setCatalogBusy(true);
|
||||
try{
|
||||
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
||||
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
|
||||
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
|
||||
};
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
|
||||
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
|
||||
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
|
||||
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||
return () => abort.abort();
|
||||
}, [sessionId]);
|
||||
const busy = submitting;
|
||||
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
|
||||
const run = async (savedDraft = draft) => {
|
||||
if (!source || busy || !savedDraft) return;
|
||||
setSubmitting(true); setError(null);
|
||||
try {
|
||||
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
|
||||
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
|
||||
});
|
||||
return data;
|
||||
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
|
||||
};
|
||||
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {SceneSettings} from '../../sceneSettings';
|
||||
|
||||
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
|
||||
export function pointDisplayKey(settings: PointDisplaySettings): string {
|
||||
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
|
||||
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
|
||||
}
|
||||
|
||||
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
|
||||
export async function streamRecordedPointDisplay(
|
||||
blueprintUrl: string, settings: PointDisplaySettings,
|
||||
identity: {applicationId: string; recordingId: string}, displayBank: string,
|
||||
signal: AbortSignal, send: (chunk: Uint8Array) => void,
|
||||
fetcher: typeof fetch = fetch,
|
||||
sourceGeneration?: string,
|
||||
): Promise<number> {
|
||||
const endpoint = new URL(blueprintUrl, window.location.origin);
|
||||
const percent = settings.pointDecimationPercent ?? 0;
|
||||
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|
||||
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|
||||
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|
||||
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
|
||||
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
|
||||
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
|
||||
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
|
||||
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
|
||||
point_decimation_percent: percent, display_bank: displayBank,
|
||||
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
|
||||
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
|
||||
throw new Error('Не удалось подготовить прореженное облако');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
let bytes = 0;
|
||||
let pending = new Uint8Array(0);
|
||||
let admitted = false;
|
||||
let complete = false;
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
const combined = new Uint8Array(pending.length + value.length);
|
||||
combined.set(pending); combined.set(value, pending.length); pending = combined;
|
||||
if (!admitted) {
|
||||
if (pending.length < 4) continue;
|
||||
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
|
||||
pending = pending.slice(4); admitted = true;
|
||||
}
|
||||
while (pending.length >= 4 && !complete) {
|
||||
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
|
||||
if (length === 0) { pending = pending.slice(4); complete = true; break; }
|
||||
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
|
||||
if (pending.length < length + 4) break;
|
||||
const payload = pending.slice(4, length + 4);
|
||||
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
|
||||
send(payload); pending = pending.slice(length + 4);
|
||||
}
|
||||
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
|
||||
}
|
||||
if (!complete || pending.length) throw new Error('Поток облака не завершён');
|
||||
return bytes;
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export type ObservationSessionStatus =
|
||||
| "interrupted"
|
||||
| "failed";
|
||||
|
||||
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
||||
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
|
||||
|
||||
export interface ObservationLabInstance {
|
||||
labId: string;
|
||||
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
|
||||
seekable: true;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
mapGeneration?: string;
|
||||
playback: {
|
||||
speed: number;
|
||||
loop: boolean;
|
||||
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
|
||||
"seekable",
|
||||
"byte_length",
|
||||
"sha256",
|
||||
"map_generation",
|
||||
"playback",
|
||||
"media_sources",
|
||||
]);
|
||||
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
|
||||
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
||||
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
|
||||
}
|
||||
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
|
||||
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
|
||||
}
|
||||
const viewerSourceUrl = requireString(
|
||||
launch.viewer_source_url,
|
||||
"launch.viewer_source_url",
|
||||
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
|
||||
integer: true,
|
||||
}),
|
||||
sha256: launch.sha256,
|
||||
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
|
||||
playback: { speed, loop: launch.playback.loop },
|
||||
mediaSources,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
|
||||
|
||||
const schema = 'missioncore.session-display-profile/v1';
|
||||
const fields = {
|
||||
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
|
||||
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
|
||||
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
|
||||
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
|
||||
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
|
||||
} as const;
|
||||
|
||||
function endpoint(sessionId: string) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
|
||||
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
|
||||
}
|
||||
|
||||
export function encodeSessionDisplay(settings: SceneSettings) {
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
|
||||
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
|
||||
}
|
||||
|
||||
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
|
||||
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
|
||||
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
|
||||
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
|
||||
}
|
||||
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
|
||||
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
|
||||
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
|
||||
}
|
||||
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|
||||
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|
||||
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|
||||
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|
||||
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
|
||||
throw new Error('Некорректные настройки отображения');
|
||||
}
|
||||
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
|
||||
}
|
||||
|
||||
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
|
||||
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
|
||||
const document = await response.json();
|
||||
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
|
||||
: decodeSessionDisplay(document, sessionId);
|
||||
}
|
||||
|
||||
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
|
||||
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
|
||||
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
|
||||
decodeSessionDisplay(await response.json(), sessionId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface SessionOverviewMetrics {
|
||||
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
|
||||
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
|
||||
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
|
||||
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
|
||||
interval_statistics_complete: boolean; gaps_over_second: number | null;
|
||||
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
|
||||
spatial_available: boolean;
|
||||
}
|
||||
export interface SessionOverview {
|
||||
schema_version: "missioncore.session-overview/v1";
|
||||
state: "queued" | "preparing" | "ready" | "error";
|
||||
session: { session_id: string; display_name: string; duration_seconds: number | null;
|
||||
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
|
||||
metrics?: SessionOverviewMetrics | null;
|
||||
scene_url?: string | null;
|
||||
message?: string;
|
||||
messages_processed?: number;
|
||||
}
|
||||
|
||||
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
|
||||
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
|
||||
signal, method: retry ? "POST" : "GET", cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Обзор записи недоступен.");
|
||||
const data = await response.json() as SessionOverview;
|
||||
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|
||||
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
|
||||
if (data.scene_url) {
|
||||
const url = new URL(data.scene_url, window.location.origin);
|
||||
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
|
||||
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
|
||||
const xmax = Math.max(1, ...valid.map(p => p[0]));
|
||||
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
|
||||
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type OverviewViewMode = "3d" | "top";
|
||||
export type OverviewRepresentation = "original" | "corrected";
|
||||
export interface OverviewComparison {
|
||||
generation: string; map_generation: string; sample_points: number; source_points: number;
|
||||
original_path_m: number; corrected_path_m: number; height_min_m: number; height_max_m: number;
|
||||
}
|
||||
export interface OverviewSpatialMetadata {
|
||||
default_representation?: OverviewRepresentation;
|
||||
height_min_m: number | null; height_max_m: number | null; sample_points: number;
|
||||
comparison?: OverviewComparison;
|
||||
}
|
||||
|
||||
function endpoint(source: string) {
|
||||
const url = new URL(source, window.location.origin);
|
||||
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
|
||||
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
|
||||
return url;
|
||||
}
|
||||
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
|
||||
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Параметры среза недоступны.");
|
||||
return response.json();
|
||||
}
|
||||
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal,
|
||||
comparisonGeneration: string | null = null, representation: OverviewRepresentation = "original") {
|
||||
if (representation === "corrected" && !comparisonGeneration) throw new Error("Исправленная версия недоступна.");
|
||||
const url = endpoint(source);
|
||||
const generation = url.searchParams.get("generation");
|
||||
url.search = "";
|
||||
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
|
||||
comparison_generation: comparisonGeneration, representation }) });
|
||||
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
|
||||
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
|
||||
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
|
||||
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
|
||||
|
||||
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
|
||||
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const applyRef = useRef(apply); applyRef.current = apply;
|
||||
const current = useRef(sessionId); current.current = sessionId;
|
||||
const writeTail = useRef(Promise.resolve());
|
||||
const revision = useRef(0);
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
if (!sessionId) return;
|
||||
const abort = new AbortController();
|
||||
const readRevision = revision.current;
|
||||
applyRef.current({...defaultSceneSettings});
|
||||
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
|
||||
if (abort.signal.aborted || current.current !== sessionId) return;
|
||||
if (revision.current === readRevision) applyRef.current(settings);
|
||||
}).catch(reason => {
|
||||
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [sessionId]);
|
||||
const edited = useCallback(() => { revision.current += 1; }, []);
|
||||
const save = useCallback((settings: SceneSettings) => {
|
||||
const id = current.current;
|
||||
if (!id) return;
|
||||
const snapshot = {...settings};
|
||||
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
|
||||
if (current.current === id) setError(null);
|
||||
}).catch(reason => {
|
||||
if (current.current === id) setError(String(reason.message ?? reason));
|
||||
});
|
||||
}, []);
|
||||
return {save, edited, error, dismissError: () => setError(null)};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
|
||||
|
||||
export function useSessionOverview(sessionId: string) {
|
||||
const [data, setData] = useState<SessionOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
setData(null); setError(null);
|
||||
const load = async (retry = false) => {
|
||||
try {
|
||||
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
|
||||
if (abort.signal.aborted) return;
|
||||
setData(next);
|
||||
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
|
||||
} catch (reason) {
|
||||
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
|
||||
}
|
||||
};
|
||||
void load(retryGeneration > 0);
|
||||
return () => { abort.abort(); clearTimeout(timer); };
|
||||
}, [sessionId, retryGeneration]);
|
||||
return { data: data?.session.session_id === sessionId ? data : null, error,
|
||||
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
|
||||
}
|
||||
|
||||
export function useSessionOverviewMode(enabled: boolean) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
|
||||
};
|
||||
window.addEventListener("keydown", close, true);
|
||||
return () => window.removeEventListener("keydown", close, true);
|
||||
}, [open]);
|
||||
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
|
||||
}
|
||||
@@ -85,6 +85,13 @@ export interface RecordedSessionRerunProfile {
|
||||
lockPerceptionCameraInteraction: boolean;
|
||||
}
|
||||
|
||||
/** A LAB owns its result presentation; it does not inherit session-view settings. */
|
||||
export interface LaboratoryResultRerunProfile
|
||||
extends Omit<RecordedSessionRerunProfile, "kind"> {
|
||||
kind: "laboratory-result";
|
||||
resultId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||
@@ -101,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||
|
||||
export type RerunViewerProfile =
|
||||
| LiveAcquisitionRerunProfile
|
||||
| RecordedSessionRerunProfile;
|
||||
| RecordedSessionRerunProfile
|
||||
| LaboratoryResultRerunProfile;
|
||||
|
||||
export type ObservationViewerProfile =
|
||||
| RerunViewerProfile
|
||||
@@ -119,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||
export function liveAcquisitionRerunProfile(
|
||||
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||
): LiveAcquisitionRerunProfile {
|
||||
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
||||
return { ...input, kind: "live-acquisition", clock: "stream_time" };
|
||||
}
|
||||
|
||||
export function recordedSessionRerunProfile(
|
||||
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||
): RecordedSessionRerunProfile {
|
||||
return { kind: "recorded-session", clock: "session_time", ...input };
|
||||
return { ...input, kind: "recorded-session", clock: "session_time" };
|
||||
}
|
||||
|
||||
export function laboratoryResultRerunProfile(
|
||||
input: Omit<LaboratoryResultRerunProfile, "kind" | "clock">,
|
||||
): LaboratoryResultRerunProfile {
|
||||
if (!input.resultId.trim()) throw new Error("LAB result identity is required");
|
||||
return { ...input, kind: "laboratory-result", clock: "session_time" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Entry intent belongs to application navigation, not a retained server job. */
|
||||
export type WorkspaceLaunchProfile = 'direct' | 'planning';
|
||||
|
||||
export function workspaceLaunchProfile(
|
||||
current: WorkspaceLaunchProfile,
|
||||
kind: string,
|
||||
requested: WorkspaceLaunchProfile = 'direct',
|
||||
): WorkspaceLaunchProfile {
|
||||
return kind === 'device' || kind === 'spatial' ? requested : current;
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import "@nodedc/tokens/themes.css";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
|
||||
import App from "./App";
|
||||
import { installedDevicePlugins } from "./composition/devicePlugins";
|
||||
import { installedDevicePlugins, installedNodeSensorContributions } from "./composition/devicePlugins";
|
||||
import { PlanningTestProvider } from "./core/missions/PlanningTestContext";
|
||||
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
||||
import { ComputeContourProvider } from "./core/system/ComputeContourContext";
|
||||
import "./styles.css";
|
||||
@@ -23,9 +24,9 @@ applyNodedcTheme(rootElement, { theme: "dark" });
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins} nodeSensorContributions={installedNodeSensorContributions}>
|
||||
<ComputeContourProvider>
|
||||
<App />
|
||||
<PlanningTestProvider><App /></PlanningTestProvider>
|
||||
</ComputeContourProvider>
|
||||
</DevicePluginHostProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -94,7 +94,7 @@ export const roots: RootDefinition[] = [
|
||||
label: "Парк",
|
||||
title: "Аппараты и устройства",
|
||||
eyebrow: "ПАРК / УСТРОЙСТВА",
|
||||
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
|
||||
description: "Реестр аппаратов, бортовые компьютеры, сенсоры и конфигурации борта.",
|
||||
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
|
||||
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
|
||||
},
|
||||
@@ -203,11 +203,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "local-device",
|
||||
root: "fleet",
|
||||
label: "Подключение",
|
||||
title: "Подключение",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
||||
root: "polygon",
|
||||
label: "Тестовые устройства",
|
||||
title: "Тестовые устройства",
|
||||
eyebrow: "LAB / ТЕСТОВЫЕ УСТРОЙСТВА",
|
||||
description: "Подключение устройств к компьютеру оператора для испытаний и записи.",
|
||||
icon: "network",
|
||||
kind: "device",
|
||||
groups: [],
|
||||
@@ -258,11 +258,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "spatial-scene",
|
||||
root: "observation",
|
||||
root: "polygon",
|
||||
label: "Пространственная сцена",
|
||||
title: "Пространственная сцена",
|
||||
eyebrow: "НАБЛЮДЕНИЕ / ЭФИР",
|
||||
description: "Облако точек, траектория, преобразования и пространственные слои в единой 3D-сцене.",
|
||||
eyebrow: "LAB / ПРОСТРАНСТВЕННАЯ СЦЕНА",
|
||||
description: "Облако точек, камеры и траектория тестового устройства в реальном времени.",
|
||||
icon: "globe",
|
||||
kind: "spatial",
|
||||
groups: [
|
||||
@@ -402,11 +402,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "mission-planner",
|
||||
root: "missions",
|
||||
root: "polygon",
|
||||
label: "Планировщик",
|
||||
title: "Планировщик миссии",
|
||||
eyebrow: "МИССИИ / ЧЕРНОВИК",
|
||||
description: "Черновик миссии, аппараты, зона, маршрут, действия и ограничения.",
|
||||
title: "Исследование планирования",
|
||||
eyebrow: "LAB / ПЛАНИРОВАНИЕ",
|
||||
description: "Эталонный участок и сопоставление нового прохода сканера.",
|
||||
icon: "edit",
|
||||
kind: "missions",
|
||||
groups: [
|
||||
@@ -414,8 +414,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
title: "Состав миссии",
|
||||
description: "Продуктовый контракт без отправки команд на борт.",
|
||||
capabilities: [
|
||||
contract("Назначение аппарата", "Один аппарат сейчас, несколько — в будущем."),
|
||||
contract("Зона и маршрут", "Точки, коридор, высота/скорость и геозоны."),
|
||||
active("Зона и маршрут", "Выбор сохранённой записи, участка траектории и направления."),
|
||||
contract("Полезная нагрузка", "Запуск сенсоров и требуемые выходные данные."),
|
||||
later("Проверка выполнимости", "Расчёт энергии, связи, столкновений и резервов."),
|
||||
],
|
||||
|
||||
@@ -1,31 +1 @@
|
||||
export type SceneProjection = "3d" | "2d" | "map";
|
||||
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
|
||||
export interface SceneSettings {
|
||||
projection: SceneProjection;
|
||||
pointSize: number;
|
||||
colorMode: PointColorMode;
|
||||
palette: PointPalette;
|
||||
customColor: string;
|
||||
accumulationSeconds: number;
|
||||
showPoints: boolean;
|
||||
showTrajectory: boolean;
|
||||
showGrid: boolean;
|
||||
showLabels: boolean;
|
||||
showCameraFrustums: boolean;
|
||||
}
|
||||
|
||||
export const defaultSceneSettings: SceneSettings = {
|
||||
projection: "3d",
|
||||
pointSize: 2.5,
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#35d7c1",
|
||||
accumulationSeconds: 12,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showGrid: true,
|
||||
showLabels: false,
|
||||
showCameraFrustums: true,
|
||||
};
|
||||
export * from "../../../packages/spatial-ui/src/sceneSettings";
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
@import "./styles/responsive.css";
|
||||
@import "./styles/observation.css";
|
||||
@import "./styles/observatory.css";
|
||||
@import "./styles/environment-settings.css";
|
||||
@import "./styles/system-telemetry.css";
|
||||
@import "./styles/artifact-health.css";
|
||||
@import "./styles/map.css";
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
.environment-settings {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-settings__copy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.environment-settings__editor {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-settings__surface {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-settings__surface > span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.environment-settings__surface .nodedc-select-anchor,
|
||||
.environment-settings__surface .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions > div {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.environment-settings__quick-actions .nodedc-select-anchor,
|
||||
.environment-settings__quick-actions .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.environment-media-playlist {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.environment-media-playlist__head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.environment-media-playlist__head > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.environment-media-playlist__head span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
}
|
||||
|
||||
.environment-media-playlist__head p,
|
||||
.environment-media-playlist__empty,
|
||||
.environment-media-playlist__timing > span,
|
||||
.environment-media-playlist__error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.environment-media-playlist__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.environment-media-playlist__items {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.environment-media-playlist__item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.environment-media-playlist__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
padding-top: 1.65rem;
|
||||
}
|
||||
|
||||
.environment-media-playlist__timing {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.environment-settings__copy,
|
||||
.environment-settings__quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.environment-settings__surface {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.environment-media-playlist__item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.environment-media-playlist__item-actions {
|
||||
justify-self: end;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.mission-planner { height: 100%; box-sizing: border-box; min-height: 360px; display: flex; flex-direction: column; gap: 16px; min-width: 0; flex: 1; padding: 8px 16px 20px; }
|
||||
.mission-planner__toolbar { display: flex; align-items: end; gap: 8px; flex-wrap: wrap; }
|
||||
.mission-planner__toolbar > .nodedc-field { flex: 1; min-width: 180px; }
|
||||
.mission-planner__toolbar > .nodedc-select-anchor { width: 260px; max-width: 100%; }
|
||||
.mission-planner__layout { display: grid; grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); gap: 16px; flex: 1; min-height: 0; }
|
||||
.mission-planner__editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.mission-planner__section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mission-planner h2 { font-size: var(--nodedc-font-size-md); margin: 0; font-weight: 600; }
|
||||
.mission-planner p, .mission-planner__check p { font-size: var(--nodedc-font-size-sm); margin: 0; line-height: 1.5; }
|
||||
.mission-planner small { font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); line-height: 1.5; }
|
||||
.mission-planner__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.mission-planner__viewer { min-height: 0; }
|
||||
.mission-planner__viewer-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; }
|
||||
.mission-planner__zone-loading { flex: 1; min-height: 320px; }
|
||||
.mission-planner__notice { font-size: var(--nodedc-font-size-sm); padding: 8px 0; }
|
||||
.mission-planner__expanded { width: min(1300px, 94vw); }
|
||||
.mission-planner__expanded .mission-planner__viewer { height: min(65vh, 620px); min-height: 360px; }
|
||||
.mission-planner__expanded .mission-route-preview svg { min-height: 140px; }
|
||||
.mission-route-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; padding: 16px; }
|
||||
.mission-route-preview svg { width: 100%; flex: 1; min-height: 250px; }
|
||||
.mission-route-preview p { margin: 0; line-height: 1.5; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
||||
.mission-route-preview__reference { stroke: var(--nodedc-text-muted); opacity: .35; stroke-width: 2; }
|
||||
.mission-route-preview__selected { stroke: var(--nodedc-text-primary); stroke-width: 2; }
|
||||
.mission-route-preview__cursor { fill: var(--nodedc-text-primary); }
|
||||
.mission-route-preview text { fill: var(--nodedc-text-muted); font-size: 12px; }
|
||||
.mission-planner__check { display: flex; flex-direction: column; gap: 16px; }
|
||||
.mission-planner__check dl { font-size: var(--nodedc-font-size-sm); }
|
||||
.mission-planner__check dl > div { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; }
|
||||
.mission-planner__check dd { margin: 0; text-align: right; }
|
||||
.mission-planner__check dt { color: var(--nodedc-text-muted); }
|
||||
@media (max-width: 900px) { .mission-planner { height: auto; } .mission-planner__layout { grid-template-columns: minmax(0, 1fr); } .mission-planner__editor { max-height: 55vh; } .mission-planner__viewer { height: 65vh; } }
|
||||
|
||||
.mission-registration { width: min(1100px, 94vw); }
|
||||
.mission-registration__scene { position: relative; height: 380px; min-height: 300px; overflow: hidden; }
|
||||
.mission-registration--expanded .mission-registration__scene { height: 65vh; }
|
||||
.mission-registration .mission-planner__viewer-head { font-size: var(--nodedc-font-size-xs); }
|
||||
|
||||
.planning-connection { width: min(1080px, 94vw); }
|
||||
.planning-live__viewport { overflow: hidden; min-height: 520px; }
|
||||
.planning-live__scene { position: relative; height: 60vh; min-height: 420px; overflow: hidden; }
|
||||
.planning-live--expanded { width: min(1400px, 96vw); }
|
||||
.planning-live--expanded .planning-live__scene { height: 70vh; }
|
||||
.planning-live .mission-planner__toolbar, .planning-live .mission-planner__actions { font-size: var(--nodedc-font-size-sm); }
|
||||
|
||||
.planning-live__scene > .session-overview__empty { position: absolute; inset: 0; }
|
||||
|
||||
.spatial-viewport-shell > .planning-live__scene { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; }
|
||||
.planning-live__summary { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 8px 4px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
||||
.planning-live__camera-note { position: absolute; right: 20px; bottom: 24px; display: flex; align-items: center; gap: 8px; font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); }
|
||||
.mission-planner__editor .mission-planner__actions { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.mission-planner__editor .inspector-control-stack > .nodedc-button, .mission-planner__editor .mission-planner__actions > .nodedc-button { width: 100%; }
|
||||
.mission-planner__viewer-head--end, .session-overview__scene-head--end { justify-content: flex-end; padding: 12px; }
|
||||
/* The pinned embedded Rerun canvas reserves 28 px above its 26 px view strip.
|
||||
Allocate this non-content chrome outside the clipped host; pointer coordinates
|
||||
remain native and the scene retains the full host height. */
|
||||
.session-overview__runtime.rerun-single-view-content { top: -54px; }
|
||||
|
||||
/* Scene-first planning: settings float within the stage and never allocate a column. */
|
||||
.planning-project { padding: 8px 16px 16px; }
|
||||
.planning-project__header-tools { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.planning-project__header-tools > .nodedc-select-anchor { width: clamp(200px, 20vw, 300px); min-width: 0; }
|
||||
.planning-project__stage { position: relative; overflow: hidden; flex: 1; min-height: 360px; }
|
||||
.planning-project__viewport { position: absolute; inset: 0; overflow: hidden; }
|
||||
.planning-project__viewport > .mission-registration__scene { flex: 1; height: auto; min-height: 0; }
|
||||
.planning-project__result-header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 12px 16px; font-size: var(--nodedc-font-size-sm); }
|
||||
.planning-project__legend { padding: 8px 16px; }
|
||||
.planning-project__inspector .inspector-control-stack > .nodedc-button { width: 100%; }
|
||||
.planning-project__inspector p { margin: 0; font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.planning-project__inspector dl { margin: 0; font-size: var(--nodedc-font-size-sm); }
|
||||
.planning-project__inspector dl > div { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 6px 0; }
|
||||
.planning-project__inspector dt { color: var(--nodedc-text-muted); }
|
||||
.planning-project__inspector dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
@media (max-width: 900px) { .mission-planner.planning-project { height: 100%; } .planning-project__header-tools { flex-wrap: wrap; } }
|
||||
@@ -1,659 +1 @@
|
||||
.scene-source-controls {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 0.85rem;
|
||||
left: 50%;
|
||||
max-width: calc(100% - 9rem);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger,
|
||||
.scene-source-control,
|
||||
.scene-focus-exit {
|
||||
display: inline-grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 50%;
|
||||
background: rgb(12 13 16 / 0.82);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.scene-navigation-hint {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
right: 0.85rem;
|
||||
bottom: 4.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger:hover,
|
||||
.scene-source-control:hover,
|
||||
.scene-focus-exit:hover,
|
||||
.scene-source-picker__trigger[data-active="true"] {
|
||||
border-color: rgb(255 255 255 / 0.18);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger > span {
|
||||
position: absolute;
|
||||
top: -0.2rem;
|
||||
right: -0.2rem;
|
||||
display: grid;
|
||||
min-width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-text-primary);
|
||||
color: #090a0c;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scene-focus-exit {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
left: 7.1rem;
|
||||
}
|
||||
|
||||
.scene-status[aria-hidden="true"],
|
||||
.scene-metrics[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observation-source-menu {
|
||||
overflow: hidden;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observation-source-menu__head,
|
||||
.observation-source-menu__foot {
|
||||
padding: 0.85rem 0.9rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong,
|
||||
.observation-source-menu__head span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.observation-source-menu__head strong {
|
||||
margin-top: 0.34rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
.observation-source-menu__head small,
|
||||
.observation-source-menu__foot {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observation-source-menu__list {
|
||||
display: grid;
|
||||
max-height: min(28rem, 60vh);
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option .nodedc-dropdown-option__description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.observation-source-option i,
|
||||
.floating-observation-window i,
|
||||
.camera-slot__status i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
flex: 0 0 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="available"],
|
||||
i[data-availability="streaming"] {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="connecting"],
|
||||
i[data-availability="declared"] {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
i[data-availability="unverified"] {
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
i[data-availability="degraded"],
|
||||
i[data-availability="error"] {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observation-source-menu__empty,
|
||||
.camera-grid__empty,
|
||||
.observation-media__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty {
|
||||
min-height: 10rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty strong,
|
||||
.observation-media__empty strong,
|
||||
.camera-grid__empty strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.observation-source-menu__empty span,
|
||||
.observation-media__empty span,
|
||||
.camera-grid__empty span {
|
||||
max-width: 24rem;
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observation-source-menu__foot {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.observation-media__asset,
|
||||
.observation-media__empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.observation-media__asset {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #050608;
|
||||
}
|
||||
|
||||
.recorded-media-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #070809;
|
||||
}
|
||||
|
||||
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-media-player__notice {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
color: rgba(247, 248, 244, 0.72);
|
||||
background: #070809;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mse-fmp4-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 9rem;
|
||||
overflow: hidden;
|
||||
background: #050608;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: rgb(5 6 8 / 0.88);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status span {
|
||||
max-width: 24rem;
|
||||
font-size: 0.59rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.48rem 0.72rem;
|
||||
font: inherit;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 760;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mse-fmp4-player__status button:hover {
|
||||
background: rgb(255 255 255 / 0.11);
|
||||
}
|
||||
|
||||
.observation-media__empty {
|
||||
min-height: 9rem;
|
||||
background: #07080a;
|
||||
}
|
||||
|
||||
.floating-observation-window__status,
|
||||
.floating-observation-window__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button {
|
||||
display: grid;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.camera-slot__head-actions .camera-slot__activation {
|
||||
width: auto;
|
||||
min-width: 5.6rem;
|
||||
height: 1.9rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.53rem;
|
||||
font-weight: 760;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions .camera-slot__activation:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.floating-observation-window__status {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.floating-observation-window__footer {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.floating-observation-window .nodedc-workspace-window__body {
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.floating-observation-window.nodedc-material-rim::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floating-observation-window[data-active="true"] {
|
||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
|
||||
}
|
||||
|
||||
.floating-observation-window--hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(9 10 13 / 0.78);
|
||||
padding: 0.4rem;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-select-anchor {
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.observation-timeline__transport {
|
||||
width: var(--nodedc-control-height-compact);
|
||||
padding: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation span,
|
||||
.observation-timeline__accumulation code {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation code {
|
||||
min-width: 2.65rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.observation-timeline__track {
|
||||
width: 100%;
|
||||
height: 0.3rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__track::-webkit-slider-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track::-moz-range-thumb {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__track:disabled {
|
||||
opacity: 0.46;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-timeline__meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.observation-timeline__meta code,
|
||||
.observation-timeline__meta small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.observation-timeline__follow:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"] {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observation-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] {
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
|
||||
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.camera-workspace__catalog {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.8rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
|
||||
grid-template-rows: none;
|
||||
grid-auto-rows: minmax(16rem, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.camera-grid[data-count="1"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.camera-grid__empty {
|
||||
min-height: 18rem;
|
||||
border-radius: 1rem;
|
||||
background: #07080a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.camera-slot {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
.camera-slot footer {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.camera-slot__head-actions,
|
||||
.camera-slot__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.camera-slot__status {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.camera-slot__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-slot footer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.camera-slot footer span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-slot footer small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.camera-timeline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] {
|
||||
height: 100%;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] > .workspace-lead,
|
||||
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cameras-workspace[data-focused="true"] .camera-grid {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.scene-status--top-left {
|
||||
top: 4.2rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline[data-accumulation="true"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-button:first-child,
|
||||
.observation-timeline__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: minmax(14rem, 1fr);
|
||||
}
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/observation.css";
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
@@ -190,18 +187,6 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
width: 20rem;
|
||||
max-width: 36vw;
|
||||
}
|
||||
|
||||
.landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 7vw, 5.6rem);
|
||||
}
|
||||
|
||||
.scene-metrics {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared shell switches to its compact overlay at 760 px, but Mission
|
||||
@@ -424,36 +409,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
top: 42%;
|
||||
right: 1.2rem;
|
||||
left: 1.2rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.landing-stage__copy h1 {
|
||||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
top: auto;
|
||||
right: 1rem;
|
||||
bottom: 3.3rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.landing-stage__footer {
|
||||
right: 1rem;
|
||||
bottom: 1.1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.landing-stage__footer span:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.metrics-grid,
|
||||
.capability-summary,
|
||||
.camera-grid,
|
||||
@@ -586,11 +541,6 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
top: 0.6rem;
|
||||
max-width: calc(100% - 8rem);
|
||||
@@ -645,20 +595,5 @@
|
||||
}
|
||||
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 6vw, 5rem);
|
||||
}
|
||||
|
||||
.landing-stage__copy p {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.landing-stage__actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
top: 1.2rem;
|
||||
right: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1 @@
|
||||
.scene-tool-window {
|
||||
--scene-tool-window-top: 5.75rem;
|
||||
--scene-tool-window-right: 27rem;
|
||||
position: fixed;
|
||||
top: var(--scene-tool-window-top);
|
||||
right: var(--scene-tool-window-right);
|
||||
max-height: calc(100vh - var(--scene-tool-window-top) - 1.5rem);
|
||||
}
|
||||
|
||||
.scene-tool-window--display {
|
||||
--scene-tool-window-top: 7.75rem;
|
||||
--scene-tool-window-right: 28.5rem;
|
||||
}
|
||||
|
||||
.scene-tool-window--layers {
|
||||
--scene-tool-window-top: 9.75rem;
|
||||
--scene-tool-window-right: 30rem;
|
||||
}
|
||||
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 11.75rem;
|
||||
--scene-tool-window-right: 31.5rem;
|
||||
}
|
||||
|
||||
.nodedc-overlay:has(> .scene-tool-window[data-scene-active="true"]) {
|
||||
z-index: calc(var(--nodedc-layer-overlay) + 1);
|
||||
}
|
||||
|
||||
.nodedc-overlay[data-placement="center"] {
|
||||
z-index: calc(var(--nodedc-layer-overlay) + 2);
|
||||
}
|
||||
|
||||
.scene-window-state {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.scene-window-code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-field-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.72rem 0.8rem;
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scene-window-note {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.scene-tool-window,
|
||||
.scene-tool-window--display,
|
||||
.scene-tool-window--layers,
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 4.75rem;
|
||||
--scene-tool-window-right: 0.875rem;
|
||||
max-height: calc(100vh - 5.625rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 44.01rem) and (max-width: 70rem) {
|
||||
.scene-tool-window,
|
||||
.scene-tool-window--display,
|
||||
.scene-tool-window--layers,
|
||||
.scene-tool-window--layout {
|
||||
--scene-tool-window-top: 13.5rem;
|
||||
--scene-tool-window-right: 1.5rem;
|
||||
max-height: calc(100vh - 15rem);
|
||||
}
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/scene-windows.css";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.session-overview { height: 100%; min-height: 360px; flex: 1; min-width: 0; }
|
||||
.session-overview__loading, .session-overview__split { height: 100%; min-height: 0; }
|
||||
.session-overview__panel { height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; }
|
||||
.session-overview__panel h2 { margin: 0; padding: 16px 18px; font: inherit; font-size: var(--nodedc-font-size-md); font-weight: 600; flex: none; }
|
||||
.session-overview__scene { position: relative; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.session-overview__runtime { position: absolute; inset: 0; }
|
||||
.session-overview__runtime iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||
.session-overview__scene-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-right: 12px; flex: none; }
|
||||
.session-overview__height { position: absolute; z-index: 2; top: 44px; right: 12px; bottom: 12px; width: 58px; display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
||||
.session-overview__height > .nodedc-range-scale { flex: 1; min-height: 0; }
|
||||
.session-overview__empty { display: flex; flex: 1; min-height: 160px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; font-size: var(--nodedc-font-size-sm); color: var(--nodedc-text-muted); }
|
||||
.session-overview__scene .session-overview__empty { position: absolute; inset: 0; }
|
||||
.session-overview__note { padding: 8px 16px 12px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); flex: none; }
|
||||
.session-overview__facts { overflow: auto; min-height: 0; padding: 0 18px 12px; font-size: var(--nodedc-font-size-sm); }
|
||||
.session-overview__facts dl { margin: 0; }
|
||||
.session-overview__facts dl > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; padding: 8px 0; }
|
||||
.session-overview__facts dt { color: var(--nodedc-text-muted); }
|
||||
.session-overview__facts dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.session-overview__facts p { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); line-height: 1.5; }
|
||||
.session-overview__chart { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.session-overview__chart svg { width: 100%; flex: 1; min-height: 0; overflow: visible; }
|
||||
.session-overview__chart text { fill: var(--nodedc-text-muted); font-size: 12px; }
|
||||
.session-overview__grid { stroke: var(--nodedc-text-muted); opacity: .2; stroke-width: 1; }
|
||||
.session-overview__series { stroke: var(--nodedc-text-primary); stroke-width: 1.5; }
|
||||
@container (max-width: 270px) { .session-overview__facts dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } .session-overview__facts dd { text-align: left; overflow-wrap: anywhere; } }
|
||||
@@ -48,116 +48,3 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--station-stage);
|
||||
}
|
||||
|
||||
.landing-stage__media,
|
||||
.landing-stage__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.landing-stage__media img,
|
||||
.landing-stage__media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.landing-stage__shade {
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
|
||||
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.landing-stage:not([data-has-media="true"]) .landing-stage__shade {
|
||||
background:
|
||||
radial-gradient(circle at 68% 42%, rgb(255 255 255 / 0.035), transparent 34%),
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.2), transparent 62%);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: clamp(2rem, 5vw, 6rem);
|
||||
width: min(39rem, 50%);
|
||||
transform: translateY(-55%);
|
||||
}
|
||||
|
||||
.landing-stage__copy h1 {
|
||||
margin: 0.75rem 0 1rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(3rem, 7vw, 7.6rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.072em;
|
||||
line-height: 0.87;
|
||||
}
|
||||
|
||||
.landing-stage__copy p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: clamp(0.8rem, 1vw, 1rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.landing-stage__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2.4rem;
|
||||
right: 2.4rem;
|
||||
display: grid;
|
||||
width: min(24rem, 30vw);
|
||||
gap: 0.6rem;
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(10 10 12 / 0.58);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.landing-stage__status > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.landing-stage__status p {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.landing-stage__footer {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 2.4rem;
|
||||
bottom: 1.8rem;
|
||||
left: 2.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
@@ -1,484 +1 @@
|
||||
.spatial-workspace {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 34rem;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spatial-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch .nodedc-button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.spatial-viewport-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #06070a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rerun-viewport,
|
||||
.rerun-viewport__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas[data-presented="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.recorded-session-preloaders {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: -10000px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport {
|
||||
--rerun-native-chrome-height: 72px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
.rerun-viewport__canvas {
|
||||
top: calc(-1 * var(--rerun-native-chrome-height));
|
||||
bottom: auto;
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__runtime {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.rerun-viewport__camera-lock {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: calc(46% - 2px);
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||
.rerun-viewport__canvas {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
background: rgb(10 11 14 / 0.82);
|
||||
padding: 0.9rem 1rem;
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.rerun-viewport__notice strong,
|
||||
.rerun-viewport__notice span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice strong {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice span {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice--error {
|
||||
background: rgb(10 11 14 / 0.9);
|
||||
}
|
||||
|
||||
.rerun-viewport__retry {
|
||||
margin-top: 0.65rem;
|
||||
border: 1px solid var(--station-hairline-strong);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
padding: 0.42rem 0.7rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.61rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rerun-viewport__retry:hover,
|
||||
.rerun-viewport__retry:focus-visible {
|
||||
background: rgb(255 255 255 / 0.14);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.busy-indicator {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 1rem;
|
||||
border: 2px solid rgb(255 255 255 / 0.12);
|
||||
border-top-color: var(--nodedc-text-primary);
|
||||
border-radius: 50%;
|
||||
animation: viewer-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes viewer-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-spatial-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__grid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spatial-axis {
|
||||
position: absolute;
|
||||
right: 2rem;
|
||||
bottom: 5.2rem;
|
||||
width: 4.4rem;
|
||||
height: 4.4rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.spatial-axis::before,
|
||||
.spatial-axis::after {
|
||||
position: absolute;
|
||||
bottom: 0.65rem;
|
||||
left: 0.75rem;
|
||||
width: 2.7rem;
|
||||
height: 1px;
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
content: "";
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.spatial-axis::before { transform: rotate(-24deg); }
|
||||
.spatial-axis::after { transform: rotate(-90deg); }
|
||||
.spatial-axis span { position: absolute; }
|
||||
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
|
||||
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
|
||||
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
|
||||
|
||||
.empty-spatial-stage__message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
max-width: 25rem;
|
||||
justify-items: center;
|
||||
gap: 0.62rem;
|
||||
transform: translate(-50%, -58%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__icon {
|
||||
display: grid;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.empty-spatial-stage__message strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__message p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.67rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scene-status,
|
||||
.scene-metrics,
|
||||
.scene-adapter-note,
|
||||
.scene-selection,
|
||||
.scene-timeline {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 0;
|
||||
background: rgb(9 10 13 / 0.74);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.42rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.scene-status small {
|
||||
max-width: 18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.scene-metrics {
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
display: grid;
|
||||
min-width: 10rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.scene-metrics > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 0;
|
||||
padding: 0.38rem 0;
|
||||
}
|
||||
|
||||
.scene-metrics > div:last-child { border-bottom: 0; }
|
||||
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
|
||||
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
|
||||
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
|
||||
|
||||
.scene-adapter-note {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
max-width: 34rem;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border-radius: 0.9rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.75rem 0.9rem;
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.4;
|
||||
transform: translate(-50%, 5.2rem);
|
||||
}
|
||||
|
||||
.scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
|
||||
|
||||
.scene-selection {
|
||||
right: 0.85rem;
|
||||
bottom: 4.6rem;
|
||||
display: grid;
|
||||
max-width: 19rem;
|
||||
gap: 0.2rem;
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.scene-selection span,
|
||||
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
|
||||
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.scene-operation-status-stack {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
left: 0.85rem;
|
||||
bottom: 7.15rem;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.scene-operation-status {
|
||||
display: inline-flex;
|
||||
max-width: min(24rem, calc(100% - 1.7rem));
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-operation-status .busy-indicator {
|
||||
width: 0.66rem;
|
||||
height: 0.66rem;
|
||||
flex-basis: 0.66rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.scene-operation-status--action {
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scene-operation-status--action:hover,
|
||||
.scene-operation-status--action:focus-visible {
|
||||
border-color: rgb(255 255 255 / 0.14);
|
||||
background: rgb(20 21 25 / 0.82);
|
||||
color: var(--nodedc-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scene-operation-status--error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.scene-timeline {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.scene-timeline__track {
|
||||
height: 0.3rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.scene-timeline__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
|
||||
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
|
||||
|
||||
.scene-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.scene-timeline__follow::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"] {
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.spatial-contract-strip {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
}
|
||||
|
||||
.spatial-contract-strip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.spatial-contract-strip i {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
|
||||
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.busy-indicator { animation: none; }
|
||||
}
|
||||
@import "../../../../packages/spatial-ui/src/spatial.css";
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
|
||||
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
|
||||
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
observationSourceStatusLabel,
|
||||
} from "../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
||||
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
rerunPresentationStatus,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
type RecordedPerceptionLoadState,
|
||||
type RecordedPointColorLoadState,
|
||||
} from "../components/RerunViewport";
|
||||
import {
|
||||
capabilityStatusLabel,
|
||||
type CapabilityStatus,
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import { sourceModeLabel } from "../presentation";
|
||||
import type { WorkspaceRendererProps } from "./contracts";
|
||||
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||
@@ -90,683 +77,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
<div className="empty-spatial-stage__grid" aria-hidden="true" />
|
||||
<div className="spatial-axis" aria-hidden="true">
|
||||
<span data-axis="x">X</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
|
||||
<strong>Визуальный источник не назначен</strong>
|
||||
<p>
|
||||
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
|
||||
открыть реальные данные.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
requestedPlaybackSeconds,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
sceneSettings,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
livePerceptionLayers,
|
||||
onLivePerceptionLayersChange,
|
||||
observationLayout,
|
||||
navigation,
|
||||
spatialControls,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
|
||||
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
|
||||
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
|
||||
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const recordedPlaybackReady = !recordedSource ||
|
||||
(recordedSessionGate === "ready" &&
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
// An explicit manual gRPC source has no Mission Core metrics producer. Its
|
||||
// native Rerun range is therefore the only available activity proof.
|
||||
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
|
||||
(liveRerunSource && !streamActive ? 1 : null);
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
|
||||
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
|
||||
const observationSources = state?.observationSources ?? [];
|
||||
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
|
||||
const pointCloudVisible = pointCloudSource
|
||||
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
|
||||
: Boolean(sourceUrl.trim());
|
||||
const mediaSources = observationSources.filter(
|
||||
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
||||
);
|
||||
const recordedPerceptionSupported =
|
||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||
const recordedPerceptionEnabled =
|
||||
showDetections2d || showSegmentation || showCuboids3d;
|
||||
// The native recorded camera remains the authoritative original. Only 2D
|
||||
// image-space overlays need Rerun's paired camera/world composition; 3D
|
||||
// cuboids are added directly to the stable spatial view.
|
||||
const unifiedPerception = recordedPerceptionSupported &&
|
||||
(showDetections2d || showSegmentation);
|
||||
const livePerceptionAvailable = !recordedSource && streamActive;
|
||||
const detections2dActive = recordedSource
|
||||
? showDetections2d
|
||||
: livePerceptionLayers.detections2d;
|
||||
const segmentationActive = recordedSource
|
||||
? showSegmentation
|
||||
: livePerceptionLayers.segmentation;
|
||||
const cuboids3dActive = recordedSource
|
||||
? showCuboids3d
|
||||
: livePerceptionLayers.cuboids3d;
|
||||
const visibleMediaSources = mediaSources.filter((source) =>
|
||||
observationLayout.visibleSourceIds.has(source.id) &&
|
||||
!source.id.startsWith("recorded.perception."),
|
||||
);
|
||||
const initialRecordedPlaybackStartSeconds = recordedSource
|
||||
? mediaSources.reduce<number | undefined>((earliest, source) => {
|
||||
if (
|
||||
source.id.startsWith("recorded.perception.") ||
|
||||
source.delivery?.kind !== "recorded-fmp4-manifest"
|
||||
) return earliest;
|
||||
const start = source.delivery.timelineStartSeconds;
|
||||
return earliest === undefined ? start : Math.min(earliest, start);
|
||||
}, undefined)
|
||||
: undefined;
|
||||
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
|
||||
const pointCloudFocused = Boolean(
|
||||
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pointCloudFocused) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
observationLayout.setFocusedSourceId(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
|
||||
const floatingSourceMaximized = !unifiedPerception &&
|
||||
Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
|
||||
const presentedViewerStatus = intentionalSourceEnd
|
||||
? "idle"
|
||||
: rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
if (recordedSessionAdmission) {
|
||||
recordedSessionAdmission.reportSpatial(
|
||||
recordedSessionAdmission.key,
|
||||
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
|
||||
);
|
||||
}
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
(next: RerunPlaybackState | null) => setPlaybackState(next),
|
||||
[],
|
||||
);
|
||||
const onPlaybackControllerChange = useCallback(
|
||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||
[],
|
||||
);
|
||||
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
|
||||
setPerceptionLoad(next);
|
||||
if (next.phase === "unavailable" || next.phase === "error") {
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
}
|
||||
}, []);
|
||||
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
|
||||
setPointColorLoad(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPerceptionLoad({
|
||||
phase: recordedSource ? "loading" : "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
|
||||
});
|
||||
setPerceptionRetryGeneration(0);
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setRecordedViewResetGeneration(0);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [recordedSource, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
setPerceptionLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRequestedPlaybackSeconds.current = null;
|
||||
}, [sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!recordedSource
|
||||
|| !playbackController
|
||||
|| requestedPlaybackSeconds === null
|
||||
|| requestedPlaybackSeconds === undefined
|
||||
|| !Number.isFinite(requestedPlaybackSeconds)
|
||||
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
|
||||
) return;
|
||||
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
|
||||
playbackController.setPlaying(false);
|
||||
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
|
||||
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const publishViewportSize = () => {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (bounds.width < 1 || bounds.height < 1) return;
|
||||
observationLayout.setViewportSize({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
};
|
||||
publishViewportSize();
|
||||
const observer = new ResizeObserver(publishViewportSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[presentedViewerStatus];
|
||||
|
||||
const viewerStatusTone = presentedViewerStatus === "ready"
|
||||
? "success"
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
const rerunViewerProfile = recordedSource
|
||||
? recordedSessionRerunProfile({
|
||||
sourceUrl,
|
||||
artifact: recordedReplay,
|
||||
autoplayWhenReady: true,
|
||||
presentationGate: recordedSessionGate,
|
||||
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
|
||||
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
|
||||
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
|
||||
view: "spatial",
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: followRecordedTrajectory,
|
||||
perceptionLayers: {
|
||||
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
},
|
||||
perceptionRetryGeneration,
|
||||
lockPerceptionCameraInteraction: unifiedPerception,
|
||||
})
|
||||
: liveAcquisitionRerunProfile({
|
||||
sourceUrl,
|
||||
liveActivitySequence: livePresentationActivitySequence,
|
||||
liveStreamId: state?.spatialSource?.id ?? null,
|
||||
liveRecoveryAuthorityIdentity: streamActive
|
||||
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
|
||||
: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="spatial-workspace"
|
||||
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
<div className="spatial-toolbar" data-viewer-controls="v1">
|
||||
<div className="spatial-toolbar__actions">
|
||||
{recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
aria-pressed="true"
|
||||
disabled
|
||||
>
|
||||
Оригинал
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={detections2dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={detections2dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowDetections2d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
detections2d: !livePerceptionLayers.detections2d,
|
||||
})}
|
||||
>
|
||||
Объекты 2D
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={segmentationActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="image" />}
|
||||
aria-pressed={segmentationActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowSegmentation((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
segmentation: !livePerceptionLayers.segmentation,
|
||||
})}
|
||||
>
|
||||
Сегментация
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="apps" />}
|
||||
aria-pressed={cuboids3dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowCuboids3d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
cuboids3d: !livePerceptionLayers.cuboids3d,
|
||||
})}
|
||||
>
|
||||
Кубы 3D
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={followRecordedTrajectory ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={followRecordedTrajectory}
|
||||
title="Удерживать orbital-пивот на риге до повторного нажатия"
|
||||
onClick={() => setFollowRecordedTrajectory((current) => !current)}
|
||||
>
|
||||
Следовать
|
||||
</Button>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
|
||||
>
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
Движок
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||
Слои
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
||||
Отображение
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="spatial-viewport-shell"
|
||||
data-primary-focused={pointCloudFocused ? "true" : undefined}
|
||||
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||
onPointColorLoadChange={onPointColorLoadChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackControllerChange={onPlaybackControllerChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
{spatialControls && !recordedSource ? (
|
||||
<div className="scene-device-controls">
|
||||
<spatialControls.View
|
||||
model={spatialControls.model}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pointCloudFocused ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-focus-exit"
|
||||
aria-label="Выйти из полноэкранного режима облака точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(null)}
|
||||
>
|
||||
<Icon name="minimize" size={16} />
|
||||
</button>
|
||||
) : !floatingSourceMaximized ? (
|
||||
<div className="scene-source-controls">
|
||||
<ObservationSourcePicker
|
||||
sources={unifiedPerception
|
||||
? observationSources.filter((source) => source.modality === "point-cloud")
|
||||
: observationSources}
|
||||
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||
pendingSourceIds={observationLayout.pendingSourceIds}
|
||||
onToggle={observationLayout.toggleSource}
|
||||
/>
|
||||
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-source-control"
|
||||
aria-label="Развернуть облако точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
|
||||
>
|
||||
<Icon name="expand" size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="scene-status scene-status--top-left"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="scene-metrics"
|
||||
aria-label="Метрики пространственной сцены"
|
||||
aria-hidden={pointCloudFocused || floatingSourceMaximized}
|
||||
>
|
||||
<div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Точек</span>
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
{streamActive ? (
|
||||
<div>
|
||||
<span>AI</span>
|
||||
<strong>
|
||||
{aiLatency === null ? "—" : formatNumber(aiLatency)}
|
||||
<small>{aiLatency === null ? "" : " мс"}</small>
|
||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!floatingSourceMaximized && recordedSource && (
|
||||
perceptionLoad.phase === "loading" ||
|
||||
perceptionLoad.phase === "error" ||
|
||||
pointColorLoad.phase === "loading" ||
|
||||
pointColorLoad.phase === "error"
|
||||
) ? (
|
||||
<div className="scene-operation-status-stack">
|
||||
{perceptionLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{perceptionLoad.phase === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-operation-status scene-operation-status--action"
|
||||
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
|
||||
>
|
||||
<Icon name="refresh" size={12} />
|
||||
<span>Повторить AI</span>
|
||||
</button>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "error" ? (
|
||||
<div className="scene-operation-status scene-operation-status--error" role="status">
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{presentedViewerStatus === "ready" && !floatingSourceMaximized ? (
|
||||
<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене">
|
||||
<span>Колесо · зум к курсору</span>
|
||||
<span>WASD · свободный проход</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
seekable={recordedSource && playbackState?.rangeNs
|
||||
? true
|
||||
: timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
rangeNs={playbackState?.rangeNs}
|
||||
currentNs={playbackState?.currentNs}
|
||||
playing={playbackState?.playing}
|
||||
onSeek={playbackController?.seek}
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
count={visibleMediaSources.length}
|
||||
boundsRef={viewportRef}
|
||||
rect={observationLayout.windowRects[source.id]}
|
||||
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||
active={observationLayout.activeFloatingSourceId === source.id}
|
||||
hidden={pointCloudFocused || unifiedPerception}
|
||||
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
|
||||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
playback={recordedSource && playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: playbackState.playing,
|
||||
} : null}
|
||||
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
onClose={() => {
|
||||
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||
observationLayout.setFloatingMaximized(source.id, false);
|
||||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
!observationLayout.visibleSourceIds.has(source.id) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
playback={playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: false,
|
||||
} : null}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
|
||||
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraSourceCard({
|
||||
source,
|
||||
focused,
|
||||
@@ -1071,54 +381,6 @@ function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const steps = [
|
||||
{ id: "01", label: "Аппарат", value: "Не назначен" },
|
||||
{ id: "02", label: "Зона", value: "Не задана" },
|
||||
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
|
||||
{ id: "04", label: "Наблюдение", value: "Облако точек" },
|
||||
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
|
||||
];
|
||||
return (
|
||||
<div className="standard-workspace mission-workspace">
|
||||
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
|
||||
<div className="mission-layout">
|
||||
<GlassSurface className="mission-sequence" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
|
||||
<h2>Новая миссия</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
</header>
|
||||
<div className="mission-steps">
|
||||
{steps.map((step) => (
|
||||
<button key={step.id} type="button" disabled>
|
||||
<span>{step.id}</span>
|
||||
<div><strong>{step.label}</strong><small>{step.value}</small></div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<GlassSurface className="mission-summary" padding="lg">
|
||||
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
|
||||
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
|
||||
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
|
||||
<div className="mission-summary__checks">
|
||||
<span><i />Аппарат</span>
|
||||
<span><i />Геометрия</span>
|
||||
<span><i />Связь</span>
|
||||
<span><i />Безопасность</span>
|
||||
</div>
|
||||
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
|
||||
const active = definition.groups.reduce(
|
||||
@@ -1155,7 +417,7 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
|
||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
switch (props.definition.kind) {
|
||||
case "spatial":
|
||||
return <SpatialWorkspace {...props} />;
|
||||
return props.launchProfile === 'planning' ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
|
||||
case "recordings":
|
||||
return <RecordingsWorkspace {...props} />;
|
||||
case "cameras":
|
||||
@@ -1167,7 +429,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
case "timeline":
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
|
||||
case "catalog":
|
||||
|
||||
@@ -14,9 +14,10 @@ import type {
|
||||
} from "../core/runtime/contracts";
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch";
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
@@ -35,7 +36,9 @@ export interface LaboratoryViewAction {
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
launchProfile?: WorkspaceLaunchProfile;
|
||||
fleetCreateRequest?: number;
|
||||
headerToolsHost?: HTMLElement | null;
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
||||
import {useBoardMonitor,monitorSeries,monitorValue} from '../../core/fleet/boardMonitor';
|
||||
import {TelemetrySeries} from '../../components/system/TelemetrySeries';
|
||||
import '../../styles/system-telemetry.css';
|
||||
import './board-monitor.css';
|
||||
|
||||
const eventLabels:Record<string,string>={'ui-error':'Ошибка интерфейса','ui-rejection':'Ошибка асинхронной операции','ui-render-error':'Ошибка представления','system-oom':'Нехватка памяти','gpu-reset':'Сброс GPU','disk-error':'Ошибка диска','usb-disconnected':'Отключение USB','usb-error':'Ошибка USB','service-failed':'Сбой службы','ui-process-exit':'Завершение процесса окна','ui-load-failed':'Ошибка загрузки окна'};
|
||||
const time=(value:number)=>new Date(value*1000).toLocaleString('ru-RU');
|
||||
export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string;back:()=>void}) {
|
||||
const [metric,setMetric]=useState('cpu.usage'),[group,setGroup]=useState('CPU'),[window,setWindow]=useState(900),[end,setEnd]=useState<number|null>(null),[mode,setMode]=useState<'min'|'max'|'mean'>('max');
|
||||
const {value,error,dismissError}=useBoardMonitor(vehicle,metric,window,end);
|
||||
const definitions=value?.definitions??{},definition=definitions[metric];
|
||||
const groups=[...new Set(Object.values(definitions).map(item=>item.group))];
|
||||
const metrics=Object.entries(definitions).filter(([,item])=>item.group===group);
|
||||
const latest=value?.latest;
|
||||
return <div className="board-monitor">
|
||||
<div><Button onClick={back}><Icon name="chevron-left"/>К аппарату</Button></div>
|
||||
<SettingsCard title="Мониторинг системы БК" description={name} actions={<StatusBadge tone={value?.fresh&&!error?'success':'neutral'}>{value?.fresh&&!error?'Данные поступают':'Нет свежих данных'}</StatusBadge>}>
|
||||
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
|
||||
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
|
||||
</SettingsCard>
|
||||
{!value?<ActivityIndicator label="Получение мониторинга БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<>
|
||||
<SettingsCard title="История нагрузки">
|
||||
<div className="board-monitor-controls">
|
||||
<Select label="Ресурс" value={group} options={groups.map(label=>({value:label,label}))} onChange={next=>{setGroup(next);const first=Object.entries(definitions).find(([,item])=>item.group===next);if(first)setMetric(first[0]);}}/>
|
||||
<Select label="Показатель" value={metric} options={metrics.map(([key,item])=>({value:key,label:`${item.resource} · ${item.label}`}))} onChange={setMetric}/>
|
||||
<Select label="Период" value={String(window)} options={[{value:'900',label:'15 минут'},{value:'3600',label:'1 час'},{value:'21600',label:'6 часов'},{value:'86400',label:'24 часа'},{value:'604800',label:'7 дней'}]} onChange={next=>setWindow(Number(next))}/>
|
||||
<Select label="Значение в интервале" value={mode} options={[{value:'max',label:'Максимум'},{value:'mean',label:'Среднее'},{value:'min',label:'Минимум'}]} onChange={next=>setMode(next as typeof mode)}/>
|
||||
</div>
|
||||
<div className="board-monitor-controls"><Button onClick={()=>setEnd(end===null?Date.now()/1000:null)}>{end===null?'Зафиксировать время':'В реальном времени'}</Button>{end!==null&&<TextField label="Конец периода" type="datetime-local" step="1" value={new Date((end-new Date(end*1000).getTimezoneOffset()*60)*1000).toISOString().slice(0,19)} onChange={event=>{const next=new Date(event.target.value).getTime()/1000;if(Number.isFinite(next))setEnd(next);}}/>}</div>
|
||||
<TelemetrySeries label={definition?`${definition.resource} · ${definition.label}`:'Загрузка CPU'} resource="Последний интервал периода" value={monitorValue(value.series.at(-1)?.[mode],definition?.unit)} values={monitorSeries(value,mode)} ceiling={definition?.unit==='%'?100:undefined}/>
|
||||
<p>{time(value.start)} — {time(value.end)} · интервал {value.bucket_seconds} с. Разрывы означают отсутствие измерений.</p>
|
||||
{!value.series.length&&<p>За выбранный период измерений нет.</p>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title={`Последние измерения · ${group}`} description={latest?time(latest.at):'Ожидание измерений'}>
|
||||
<ResourceList aria-label="Показатели системы">{metrics.map(([key,item])=><li key={key}><ResourceRow title={`${item.resource} · ${item.label}`} description={latest?.values[key]==null?item.reason??'Счётчик недоступен':undefined} metadata={monitorValue(latest?.values[key],item.unit)}/></li>)}</ResourceList>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="События системы и приложения" description="За выбранный период">
|
||||
{value.events.length?<ResourceList aria-label="События БК">{value.events.map((event,index)=><li key={`${event.at}-${index}`}><ResourceRow title={eventLabels[event.code]??'Событие системы'} description={time(event.at)}/></li>)}</ResourceList>:<p>Событий не получено.</p>}
|
||||
</SettingsCard>
|
||||
</>}
|
||||
<ToastStack items={error?[{id:'monitor-error',title:error,tone:'error',durationMs:null}]:[]} onDismiss={dismissError}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import {useMemo} from 'react';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=registry.sensorContributions;
|
||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||
import {BoardMonitorView} from './BoardMonitorView';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
|
||||
import "./fleet.css";
|
||||
import { VehicleSensors } from "./VehicleSensors";
|
||||
@@ -19,6 +21,8 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [observationOpen,setObservationOpen]=useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
@@ -40,6 +44,8 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSensorOpen(false);}}/>;
|
||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
@@ -51,11 +57,13 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.board-monitor { display: grid; gap: var(--nodedc-space-4); min-width: 0; }
|
||||
.board-monitor-controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--nodedc-space-3); align-items: end; margin-bottom: var(--nodedc-space-3); }
|
||||
.board-monitor p { font-size: var(--nodedc-font-size-sm); color: var(--nodedc-text-secondary); }
|
||||
@@ -7,3 +7,7 @@
|
||||
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.fleet-facts dt { color: var(--nodedc-text-secondary); }
|
||||
.fleet-facts dd { color: var(--nodedc-text-muted); }
|
||||
|
||||
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
|
||||
.fleet-board-actions > * { flex:1; }
|
||||
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Button,DragDropRoot,DraggableItem,DropZone,GlassSurface,Icon,IconButton,LoadingRegion,Select,StatusBadge,Switch,Window} from '@nodedc/ui-react';
|
||||
import {useDevicePluginHost} from '../../../core/device-plugins/DevicePluginHost';
|
||||
import {createFleetSensorTransport} from '../../../core/fleet/sensorTransport';
|
||||
import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedObservationIDs} from '../../../core/fleet/observationLayout';
|
||||
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
|
||||
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
import './observation.css';
|
||||
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:string;name:string;enabled:boolean;back:()=>void}){
|
||||
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
||||
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
||||
const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState<string|null>(null);
|
||||
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
|
||||
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
|
||||
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
|
||||
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
|
||||
const groups=(inventory?.items??[]).flatMap(device=>{
|
||||
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation;
|
||||
const views=contribution?.views(device)??cameraObservationViews(device);
|
||||
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
||||
});
|
||||
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}});
|
||||
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
|
||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
||||
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||
return <><div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения</h2><span>{name}</span></div><div className="observation-actions"><Button onClick={back}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
|
||||
<DropZone id={source.key} className="observation-panel">
|
||||
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
|
||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
|
||||
<header className="observation-panel__header">
|
||||
{handle}
|
||||
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
|
||||
<div className="observation-actions">
|
||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||
{source.key!=='map'&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||
</div>
|
||||
</header>
|
||||
<ObservationMount element={media.current.get(source.key)!}/>
|
||||
</GlassSurface>
|
||||
}</DraggableItem>
|
||||
</DropZone>,mounts.current.get(source.key)!,source.key))}
|
||||
</DragDropRoot>
|
||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
|
||||
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
|
||||
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
||||
</div></Window>
|
||||
</section>,portal)}</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useLayoutEffect,useRef} from 'react';
|
||||
import {SplitPane} from '@nodedc/ui-react';
|
||||
import type {ObservationLayout} from '../../../core/fleet/observationLayout';
|
||||
|
||||
/** Moving a mount preserves media elements, peer lifetimes and native renderer state. */
|
||||
export function ObservationMount({element,className='observation-mount'}:{element:HTMLElement;className?:string}){
|
||||
const ref=useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(()=>{const host=ref.current!;host.append(element);return()=>{if(element.parentElement===host)element.remove();};},[element]);
|
||||
return <div ref={ref} className={className}/>;
|
||||
}
|
||||
export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map<string,HTMLElement>;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){
|
||||
if(!ids.length)return null;
|
||||
if(ids.length===1)return <ObservationMount element={mounts.get(ids[0])!}/>;
|
||||
const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMemo,useRef,useState} from 'react';
|
||||
import {CesiumMapRenderer,initialMapRuntimeState,type CesiumMapRendererHandle,type MapCamera} from '@nodedc/map-cesium-react';
|
||||
import {Button,Icon,IconButton,Switch,Window} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from '../../../../../../packages/sensor-ui/src/ObservationHeader';
|
||||
import type {ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {defaultMapViewDocument} from '../../../core/map/mapView';
|
||||
import {mapPresentation} from '../../../components/map/mapPresentation';
|
||||
|
||||
export function ObservationMap({vehicleID,header}:{vehicleID:string;header:ObservationHeaderTargets}){
|
||||
const defaults=useMemo(()=>defaultMapViewDocument().view,[]),renderer=useRef<CesiumMapRendererHandle>(null);
|
||||
const [settings,setSettings]=useState(false),[state,setState]=useState(initialMapRuntimeState),[generation,setGeneration]=useState(0);
|
||||
const storageKey=`missioncore.observation-map.v1:${vehicleID}`;
|
||||
const [camera]=useState(()=>{try{const value=JSON.parse(localStorage.getItem(storageKey)??'null') as MapCamera|null;return value&&['longitude','latitude','height','heading','pitch','roll'].every(key=>Number.isFinite(value[key as keyof MapCamera]))?value:defaults.camera;}catch{return defaults.camera;}});
|
||||
const [layers,setLayers]=useState({...defaults.layerVisibility,grid:false,targets:false});
|
||||
const ready=state.phase==='ready';
|
||||
const available=Object.values(state.providers).some(provider=>provider.phase==='ready');
|
||||
return <div className="observation-source">
|
||||
<ObservationHeader targets={header} live={ready} label={ready?'Карта доступна':state.phase==='loading'?'Загрузка карты':state.phase==='degraded'?'Часть слоёв недоступна':'Карта недоступна'}><IconButton label="Слои карты" onClick={()=>setSettings(true)}><Icon name="layers"/></IconButton></ObservationHeader>
|
||||
<div className="observation-source__media observation-map" data-available={available}><CesiumMapRenderer key={generation} ref={renderer} runtimeConfigUrl="/api/v1/map/runtime-config" camera={camera} layers={layers} settings={mapPresentation(defaults.visualSettings)} cacheIntent={{enabled:true,no_overwrite:true}} onRuntimeStateChange={setState} onCameraChange={value=>{try{localStorage.setItem(storageKey,JSON.stringify(value));}catch{/* Browser storage may be unavailable. */}}}/></div>
|
||||
{!available&&state.phase!=='loading'&&<div className="observation-source__notice"><p>Не удалось загрузить картографические слои.</p><Button onClick={()=>setGeneration(v=>v+1)}>Повторить загрузку карты</Button></div>}
|
||||
<Window open={settings} onClose={()=>setSettings(false)} title="Слои карты" size="sm">
|
||||
<div className="observation-settings">{([{id:'imagery',name:'Спутниковые снимки'},{id:'terrain',name:'Рельеф'},{id:'buildings',name:'Здания OSM'}] as const).map(item=><Switch key={item.id} label={item.name} checked={layers[item.id]} onChange={value=>setLayers(current=>({...current,[item.id]:value}))}/>)}
|
||||
</div>
|
||||
</Window>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.observation-center { display:flex; flex-direction:column; gap:var(--nodedc-space-3); padding:var(--nodedc-space-4); min-width:0; height:max(600px, calc(100dvh - 260px)); }
|
||||
.observation-center--expanded { position:fixed; inset:0; z-index:var(--nodedc-layer-overlay); background:var(--nodedc-canvas); height:100dvh; box-sizing:border-box; }
|
||||
.observation-toolbar, .observation-summary, .observation-actions { display:flex; align-items:center; gap:var(--nodedc-space-2); }
|
||||
.observation-toolbar { justify-content:space-between; flex-wrap:wrap; }
|
||||
.observation-toolbar h2, .observation-panel h3 { margin:0; }
|
||||
.observation-toolbar span { color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-sm); }
|
||||
.observation-actions { flex-shrink:0; }
|
||||
.observation-deck { flex:1; min-height:0; min-width:0; }
|
||||
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
|
||||
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
|
||||
.observation-panel__surface > .observation-mount { flex:1; }
|
||||
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
|
||||
.observation-panel__title { display:flex; align-items:center; gap:var(--nodedc-space-2); flex:1; min-width:0; }
|
||||
.observation-header-slot, .observation-header-slot > div { display:flex; align-items:center; gap:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-header-slot:has(> div:empty) { display:none; }
|
||||
.observation-layer-select { min-width:0; max-width:120px; }
|
||||
.observation-layer-select button { max-width:100%; }
|
||||
.observation-layer-select button > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
|
||||
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
|
||||
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
|
||||
.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
|
||||
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
|
||||
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
|
||||
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
|
||||
.observation-layer > * { min-width:0; width:100%; }
|
||||
.observation-layer > .nodedc-switch { text-align:left; }
|
||||
.observation-empty { display:grid; place-content:center; height:100%; text-align:center; }
|
||||
.observation-decoder { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
|
||||
@media(max-width:700px) { .observation-toolbar .observation-actions { flex-wrap:wrap; } .observation-center { height:max(650px, calc(100dvh - 200px)); } .observation-center--expanded { height:100dvh; } }
|
||||
|
||||
.observation-map[data-available="false"] > [data-nodedc-map-renderer] { visibility:hidden; }
|
||||
|
||||
/* Transform the live panel as one item; reveal it across nested split boundaries. */
|
||||
.observation-panel-drag { position:relative; height:100%; min-height:0; }
|
||||
.observation-panel-drag.is-dragging { z-index:var(--nodedc-layer-panel); }
|
||||
.observation-split:has(.observation-panel-drag.is-dragging),
|
||||
.observation-split > .nodedc-split-pane__panel:has(.observation-panel-drag.is-dragging) { position:relative; overflow:visible; z-index:var(--nodedc-layer-panel); }
|
||||
@container (max-width:340px) {
|
||||
.observation-panel__header { flex-wrap:wrap; }
|
||||
.observation-panel__title { min-width:80px; }
|
||||
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export function useObservationInventory(transport:SensorTransport){
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);
|
||||
useEffect(()=>{
|
||||
let active=true,running=false,revision=0,last=0;
|
||||
const receive=(value:SensorInventory)=>{if(active){revision++;last=Date.now();setInventory(value);}};
|
||||
// A lost SSE connection is not evidence that a working camera stopped.
|
||||
// Retain the last inventory for one bounded heartbeat window and verify via HTTP.
|
||||
const unavailable=()=>{if(active&&Date.now()-last>15000)setInventory(value=>value?{...value,fresh:false}:value);};
|
||||
const poll=async()=>{if(running||Date.now()-last<4000)return;running=true;const before=revision;try{const value=await transport.inventory();if(before===revision)receive(value);}catch{if(before===revision)unavailable();}finally{running=false;}};
|
||||
void poll();const unsubscribe=transport.subscribe?.(receive,()=>{unavailable();void poll();});const timer=setInterval(()=>{unavailable();void poll();},5000);
|
||||
return()=>{active=false;clearInterval(timer);unsubscribe?.();};
|
||||
},[transport]);
|
||||
return inventory;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {mapPresentation} from '../../components/map/mapPresentation';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -207,60 +208,7 @@ export function WorldMapWorkspace({
|
||||
runtimeConfigUrl="/api/v1/map/runtime-config"
|
||||
camera={draft.view.camera}
|
||||
layers={draft.view.layerVisibility}
|
||||
settings={{
|
||||
atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled,
|
||||
atmosphere_hue: draft.view.visualSettings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
draft.view.visualSettings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
draft.view.visualSettings.atmosphereBrightness,
|
||||
fog_enabled: draft.view.visualSettings.fogEnabled,
|
||||
fog_density: draft.view.visualSettings.fogDensity,
|
||||
sun_enabled: draft.view.visualSettings.sunEnabled,
|
||||
sun_hour: draft.view.visualSettings.sunHour,
|
||||
sun_intensity: draft.view.visualSettings.sunIntensity,
|
||||
shadows_enabled: draft.view.visualSettings.shadowsEnabled,
|
||||
monochrome_enabled: draft.view.visualSettings.monochromeEnabled,
|
||||
monochrome_color: draft.view.visualSettings.monochromeColor,
|
||||
imagery_brightness: draft.view.visualSettings.imageryBrightness,
|
||||
imagery_contrast: draft.view.visualSettings.imageryContrast,
|
||||
imagery_saturation: draft.view.visualSettings.imagerySaturation,
|
||||
imagery_gamma: draft.view.visualSettings.imageryGamma,
|
||||
imagery_hue: draft.view.visualSettings.imageryHue,
|
||||
imagery_alpha: draft.view.visualSettings.imageryAlpha,
|
||||
globe_color: draft.view.visualSettings.globeColor,
|
||||
background_color: draft.view.visualSettings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
draft.view.visualSettings.terrainExaggeration,
|
||||
buildings_color: draft.view.visualSettings.buildingsColor,
|
||||
buildings_opacity: draft.view.visualSettings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
draft.view.visualSettings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: draft.view.visualSettings.gridLodEnabled,
|
||||
grid_height_meters: draft.view.visualSettings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
draft.view.visualSettings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
draft.view.visualSettings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
draft.view.visualSettings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
draft.view.visualSettings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
draft.view.visualSettings.gridLod3StepKm,
|
||||
grid_radius_km: draft.view.visualSettings.gridRadiusKm,
|
||||
grid_line_width: draft.view.visualSettings.gridLineWidth,
|
||||
grid_color: draft.view.visualSettings.gridColor,
|
||||
grid_opacity: draft.view.visualSettings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
draft.view.visualSettings.gridDotsEnabled,
|
||||
grid_dots_size: draft.view.visualSettings.gridDotsSize,
|
||||
grid_dots_color: draft.view.visualSettings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
draft.view.visualSettings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
draft.view.visualSettings.cameraAnimationEnabled,
|
||||
}}
|
||||
settings={mapPresentation(draft.view.visualSettings)}
|
||||
cacheIntent={{
|
||||
enabled: draft.view.cacheIntent.enabled,
|
||||
no_overwrite: draft.view.cacheIntent.noOverwrite,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
|
||||
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
|
||||
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
|
||||
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
|
||||
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
|
||||
import { MissionRoutePreview } from '../../components/missions/MissionRoutePreview';
|
||||
import { RegistrationScene } from '../../components/missions/RegistrationScene';
|
||||
import { PlanningProjectSettings } from '../../components/missions/PlanningProjectSettings';
|
||||
import { PlanningProjectResult } from '../../components/missions/PlanningProjectResult';
|
||||
import { PlanningProjectSelect } from '../../components/missions/PlanningProjectSelect';
|
||||
import '../../styles/session-overview.css';
|
||||
import '../../styles/mission-planner.css';
|
||||
|
||||
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
|
||||
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
|
||||
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
||||
const [view,setView]=useState<'cloud'|'route'>('cloud');
|
||||
const [starting,setStarting]=useState(false);
|
||||
const [pending,setPending]=useState<(()=>void)|null>(null);
|
||||
const bounds=useRef<HTMLDivElement>(null);
|
||||
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:16,width:390,height:600});
|
||||
const [maximized,setMaximized]=useState(false);
|
||||
const project=projects.detail;
|
||||
const editing=creating||project?.kind==='draft';
|
||||
const loadedDraft=useRef('');
|
||||
useEffect(()=>{
|
||||
if(project?.kind==='draft'&&loadedDraft.current!==project.key) {
|
||||
loadedDraft.current=project.key; void p.openDraft(project.id); setSettingsOpen(true);
|
||||
}
|
||||
},[project?.key]);
|
||||
const replace=(action:()=>void)=>{
|
||||
if(editing&&p.dirty&&(p.sessionId||p.name))setPending(()=>action);else action();
|
||||
};
|
||||
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
|
||||
const newProject=()=>replace(()=>{
|
||||
projects.select('');p.newDraft();setView('cloud');
|
||||
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
|
||||
});
|
||||
const start=async()=>{
|
||||
if(starting)return;setStarting(true);
|
||||
try {
|
||||
const draft=p.saved&&!p.dirty?p.saved:await p.save();
|
||||
if(!draft)return;
|
||||
const next=await live.begin(draft);
|
||||
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
|
||||
projects.refresh();
|
||||
} finally {setStarting(false);}
|
||||
};
|
||||
const tools=<div className="planning-project__header-tools">
|
||||
<PlanningProjectSelect value={projects.key} items={projects.items} disabled={starting} onChange={choose}
|
||||
onRemove={async target => {
|
||||
await projects.remove(target);
|
||||
if (target.key === projects.key) {
|
||||
setCreating(false); setSettingsOpen(false); loadedDraft.current = ''; p.newDraft();
|
||||
}
|
||||
}} />
|
||||
<IconButton label="Создать проект" disabled={starting} onClick={newProject}><Icon name="plus"/></IconButton>
|
||||
<IconButton label="Обновить проекты" loading={projects.loading} disabled={starting} onClick={()=>{projects.refresh();p.refresh();}}><Icon name="refresh"/></IconButton>
|
||||
<IconButton label="Настройки" aria-pressed={settingsOpen} onClick={()=>setSettingsOpen(v=>!v)}><Icon name="settings"/></IconButton>
|
||||
</div>;
|
||||
const zoneControls=<SegmentedControl label="Представление зоны" value={view} onChange={setView} items={[{value:'cloud',label:'Облако'},{value:'route',label:'Маршрут'}]}/>;
|
||||
return <div className="mission-planner planning-project">
|
||||
{headerToolsHost?createPortal(tools,headerToolsHost):tools}
|
||||
<div ref={bounds} className="planning-project__stage" onKeyDown={event=>{
|
||||
if(event.key==='Escape'&&!event.defaultPrevented&&settingsOpen){event.preventDefault();event.stopPropagation();setSettingsOpen(false);}
|
||||
}}>
|
||||
<GlassSurface className="planning-project__viewport session-overview__panel" radius="panel">
|
||||
{editing?<>
|
||||
{(view!=='cloud'||!p.source)&&<header className="mission-planner__viewer-head mission-planner__viewer-head--end">{zoneControls}</header>}
|
||||
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
|
||||
:p.sourceError||p.sourceChanged?<div className="session-overview__empty" role="alert"><span>{p.sourceError||'Исходная запись изменилась.'}</span><Button onClick={p.retrySource}>Повторить загрузку</Button></div>
|
||||
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
|
||||
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} generation={p.source.generation} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
|
||||
</>:projects.error?<div className="session-overview__empty" role="alert"><span>{projects.error}</span><Button onClick={projects.refresh}>Повторить</Button></div>
|
||||
:project?<>
|
||||
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
|
||||
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
|
||||
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
|
||||
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись прохода сохранена отдельно от результата.</small></div>}
|
||||
{project.scene_url&&<small className="planning-project__legend">{project.result?.correspondence_colors?'Серый — эталон · цветной — повторный проход · зелёный — точки в пределах 0,5 м':'Серый — эталон · зелёный — повторный проход (ранний формат записи)'}</small>}
|
||||
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
|
||||
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
|
||||
</GlassSurface>
|
||||
{settingsOpen&&<WorkspaceWindow boundsRef={bounds} rect={rect} onRectChange={setRect} maximized={maximized} onMaximizedChange={setMaximized}
|
||||
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
|
||||
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
|
||||
className="planning-project__inspector">
|
||||
{editing?<PlanningProjectSettings p={p} onStart={()=>void start()} starting={starting}/>
|
||||
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
|
||||
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{if(await live.select(project.id))openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene','planning');}}>Открыть текущий проход</Button>}
|
||||
{live.error&&editing&&<p role="alert">{live.error}</p>}
|
||||
</WorkspaceWindow>}
|
||||
</div>
|
||||
<ConfirmationModal open={!!pending} title="Закрыть настройки проекта?" description="Несохранённые изменения будут потеряны." confirmLabel="Продолжить" onClose={()=>setPending(null)} onConfirm={()=>{pending?.();setPending(null);}}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Checker,Icon,LoadingRegion,RangeControl,SegmentedControl} from '@nodedc/ui-react';
|
||||
import {SpatialToolbarActions} from '../../../../../packages/spatial-ui/src/SpatialToolbarActions';
|
||||
import {planningActivity,planningMatchCurrent,planningStatus} from '../../core/missions/planningPresentation';
|
||||
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
import {PlanningLiveScene} from '../../components/missions/PlanningLiveScene';
|
||||
import {PlanningSceneToolWindow} from '../../components/missions/PlanningSceneToolWindow';
|
||||
import {SpatialWorkspace} from '../spatial/SpatialWorkspace';
|
||||
import type {WorkspaceRendererProps} from '../contracts';
|
||||
import '../../styles/mission-planner.css';
|
||||
|
||||
/** Planning supplies geometry and evidence to the ordinary scene, never replaces its camera/recording owners. */
|
||||
export function PlanningSpatialWorkspace(props:WorkspaceRendererProps){
|
||||
const p=usePlanningTest();
|
||||
const [tool,setTool]=useState<'layers'|'display'|null>(null);
|
||||
const [view,setView]=useState('3d'),[reset,setReset]=useState(0);
|
||||
const [layers,setLayers]=useState({reference:true,query:true,trajectory:true,grid:true});
|
||||
const [pointSize,setPointSize]=useState(1.8);
|
||||
const t=p.test;if(!t)return null;
|
||||
const terminal=planningTestTerminal(t),readyMatch=planningMatchCurrent(t,!!p.error);
|
||||
const status=planningStatus(t,p.error);
|
||||
const options={...layers,mode:view,point_size:pointSize,reset};
|
||||
const heightMin=t.scene_height_min_m,heightMax=t.scene_height_max_m;
|
||||
const heightBounds=typeof heightMin==='number'&&typeof heightMax==='number'&&Number.isFinite(heightMin)&&Number.isFinite(heightMax)&&heightMax>heightMin?{min:heightMin,max:heightMax}:null;
|
||||
const renderer=t.scene_available?<PlanningLiveScene runId={t.id} options={options} active={t.state==='running'} revision={t.scene_revision} heightBounds={heightBounds}/>:<LoadingRegion loading={t.state==='preparing'} label="Подготовка эталона" className="planning-live__scene"><p>{t.message}</p></LoadingRegion>;
|
||||
const toolbar=<>
|
||||
<SegmentedControl label="Вид сцены планирования" value={view} onChange={setView} items={[{value:'top',label:'Сверху'},{value:'3d',label:'3D'}]}/>
|
||||
<Button size="compact" icon={<Icon name="refresh"/>} onClick={()=>setReset(v=>v+1)}>Сброс вида</Button>
|
||||
<SpatialToolbarActions activeTool={tool} openLayers={()=>setTool(v=>v==='layers'?null:'layers')} openDisplay={()=>setTool(v=>v==='display'?null:'display')}/>
|
||||
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button size="compact" icon={<Icon name="refresh"/>} loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
|
||||
{!terminal&&<Button size="compact" loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}
|
||||
</>;
|
||||
const footer=<div className="planning-live__summary">
|
||||
<span>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</span>
|
||||
<span>Серый — эталон · цветной — новый проход · зелёный — совпавшие точки</span>
|
||||
{t.query_session_id&&<span>Запись прохода сохранена отдельно от расчёта исследования.</span>}
|
||||
{terminal&&t.result&&<span>Последний расчёт: {t.result.status==='candidate'?'кандидат совмещения':'совпадение не подтверждено'} · {(t.result.overlap*100).toFixed(1)}%.</span>}
|
||||
</div>;
|
||||
return <>
|
||||
<SpatialWorkspace {...props} recordedReplay={null} recordedSessionAdmission={null} visualProfile={{renderer,toolbar,status,footer,activity:planningActivity(t,p.error),
|
||||
metrics:<><div><span>Пройдено в исследовании</span><strong>{t.distance_m.toFixed(1)} м</strong></div>
|
||||
<div><span>Точек</span><strong>{(t.query_points??0).toLocaleString('ru-RU')}</strong></div>
|
||||
<div><span>Совпадение</span><strong>{readyMatch&&t.result?`${(t.result.overlap*100).toFixed(1)}%`:'—'}</strong></div></>,
|
||||
mediaFallback:<div className="planning-live__camera-note"><Icon name="camera"/><span>Нет активного изображения камеры</span></div>,
|
||||
tools:boundsRef=>tool&&<PlanningSceneToolWindow boundsRef={boundsRef} title={tool==='layers'?'Слои':'Отображение'} onClose={()=>setTool(null)}>
|
||||
{tool==='layers'?(['reference','query','trajectory'] as const).map((key,i)=><Checker key={key} checked={layers[key]} label={['Эталон','Новый проход','Траектории'][i]} onChange={value=>setLayers(v=>({...v,[key]:value}))}/>)
|
||||
:<><RangeControl label="Размер точки" value={pointSize} min={.5} max={12} step={.5} exactValueBounds={{min:.5,max:12}} onChange={setPointSize}/><Checker label="Сетка" checked={layers.grid} onChange={grid=>setLayers(v=>({...v,grid}))}/></>}
|
||||
</PlanningSceneToolWindow>,
|
||||
}}/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import { Button, GlassSurface, LoadingRegion, SplitPane } from "@nodedc/ui-react";
|
||||
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
||||
import { SessionOverviewScene } from "../../components/observation/SessionOverviewScene";
|
||||
import { SessionIntervalChart } from "../../components/observation/SessionIntervalChart";
|
||||
import "../../styles/session-overview.css";
|
||||
|
||||
const fmt = (n: number | null | undefined, digits = 0, suffix = "") => n == null || !Number.isFinite(n)
|
||||
? "—" : `${n.toLocaleString("ru-RU", { maximumFractionDigits: digits })}${suffix}`;
|
||||
const duration = (n: number | null | undefined) => n == null ? "—" : `${Math.floor(n / 60)} мин ${Math.round(n % 60)} с`;
|
||||
function initialSize(key: string, fallback: number) {
|
||||
try { const n = Number(localStorage.getItem(key)); return n >= 25 && n <= 80 ? n : fallback; } catch { return fallback; }
|
||||
}
|
||||
function usePaneSize(name: string, fallback: number) {
|
||||
const key = `missioncore.session-overview.layout.v1.${name}`;
|
||||
const [size, setSize] = useState(() => initialSize(key, fallback));
|
||||
return [size, (n: number) => { setSize(n); try { localStorage.setItem(key, String(n)); } catch { /* layout remains usable */ } }] as const;
|
||||
}
|
||||
|
||||
export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
|
||||
const { data, error, retry } = useSessionOverview(sessionId);
|
||||
const [top, setTop] = usePaneSize("top", 70);
|
||||
const [left, setLeft] = usePaneSize("left", 68);
|
||||
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
||||
const failure = error || (data?.state === "error" ? data.message : null);
|
||||
const m = data?.metrics;
|
||||
const facts: [string, string][] = [
|
||||
["Начало записи", data?.session.started_at_utc ? new Date(data.session.started_at_utc).toLocaleString("ru-RU") : "—"],
|
||||
["Длительность сессии", duration(data?.session.duration_seconds)],
|
||||
["Поток облака", duration(m?.stream_seconds)],
|
||||
["Кадры облака", fmt(m?.point_frames)],
|
||||
["Наблюдения точек", fmt(m?.point_count)],
|
||||
["Положения сканера", fmt(m?.pose_frames)],
|
||||
["Путь по траектории", fmt(m?.path_m, 2, " м")],
|
||||
["От старта до финиша", fmt(m?.start_end_m, 2, " м")],
|
||||
["Средняя частота", fmt(m?.mean_hz, 2, " Гц")],
|
||||
["Максимальный интервал", fmt(m?.interval_max_s, 3, " с")],
|
||||
["Интервалы больше секунды", fmt(m?.gaps_over_second)],
|
||||
["Пропуски номеров", fmt(m?.sequence_gaps)],
|
||||
["Ошибки чтения кадров", fmt(m?.decode_errors)],
|
||||
["Объём исходных данных", fmt(data ? data.session.total_bytes / 1_000_000 : null, 1, " МБ")],
|
||||
];
|
||||
return <div className="session-overview" aria-label="Информация о записи">
|
||||
<LoadingRegion loading={pending} label={data?.state === "queued" ? "Ожидание подготовки обзора" : "Подготовка обзора записи"} className="session-overview__loading">
|
||||
{failure ? <div className="session-overview__empty" role="alert"><span>{failure}</span><Button onClick={retry}>Повторить</Button></div> : pending ? null : <SplitPane orientation="horizontal" primarySize={top} onPrimarySizeChange={setTop} minPrimarySize={35} minSecondarySize={20}
|
||||
separatorLabel="Высота графика интервалов" className="session-overview__split"
|
||||
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} separatorLabel="Ширина облака и сводки" className="session-overview__split"
|
||||
primary={<GlassSurface className="session-overview__panel" radius="panel">
|
||||
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} compareVersions /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
|
||||
{!m?.spatial_available && <span className="session-overview__note">Доступны сведения из каталога записи.</span>}
|
||||
</GlassSurface>}
|
||||
secondary={<GlassSurface className="session-overview__panel" radius="panel">
|
||||
<h2>Сведения об исходной записи</h2><div className="session-overview__facts">
|
||||
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
|
||||
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
|
||||
</div>
|
||||
</GlassSurface>} />}
|
||||
secondary={<GlassSurface className="session-overview__panel" radius="panel"><h2>Интервалы поступления кадров</h2>
|
||||
<SessionIntervalChart chart={m?.chart ?? []} bucketSeconds={m?.chart_bucket_seconds ?? 1} />
|
||||
</GlassSurface>} />}
|
||||
</LoadingRegion>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import {SpatialScene, EmptySpatialStage} from '../../../../../packages/spatial-ui/src';
|
||||
import {SpatialToolbarActions} from "../../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon, IconButton } from "@nodedc/ui-react";
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
} from "../../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../../components/FloatingObservationWindow";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../../core/observation/recordedSessionAdmission";
|
||||
import { liveRerunRecoveryAuthorityIdentity } from "../../core/observation/liveReceiverWatchdog";
|
||||
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
rerunPresentationStatus,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
type RecordedPerceptionLoadState,
|
||||
type RecordedPointColorLoadState,
|
||||
} from "../../components/RerunViewport";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../../presentation";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
export interface SpatialWorkspaceProfile {
|
||||
renderer: ReactNode;
|
||||
toolbar: ReactNode;
|
||||
status: {label: string; tone: 'neutral'|'success'|'warning'|'danger'; message?: string; pulse?: boolean};
|
||||
metrics: ReactNode;
|
||||
footer: ReactNode;
|
||||
sourceControls?: ReactNode;
|
||||
mediaFallback?: ReactNode;
|
||||
tools?: (boundsRef: RefObject<HTMLDivElement|null>) => ReactNode;
|
||||
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
|
||||
}
|
||||
export function SpatialWorkspace({
|
||||
launchProfile = 'direct',
|
||||
state,
|
||||
sourceUrl,
|
||||
requestedPlaybackSeconds,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
sceneSettings,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
livePerceptionLayers,
|
||||
onLivePerceptionLayersChange,
|
||||
observationLayout,
|
||||
navigation,
|
||||
spatialControls,
|
||||
visualProfile,
|
||||
}: WorkspaceRendererProps & { visualProfile?: SpatialWorkspaceProfile }) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
|
||||
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
|
||||
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
|
||||
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||
const recordedSource = !visualProfile && (Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl));
|
||||
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const recordedPlaybackReady = !recordedSource ||
|
||||
(recordedSessionGate === "ready" &&
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
// An explicit manual gRPC source has no Mission Core metrics producer. Its
|
||||
// native Rerun range is therefore the only available activity proof.
|
||||
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
|
||||
(liveRerunSource && !streamActive ? 1 : null);
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
|
||||
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
|
||||
const observationSources = (state?.observationSources ?? []).filter(source => !visualProfile || source.transport !== "recording");
|
||||
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
|
||||
const pointCloudVisible = pointCloudSource
|
||||
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
|
||||
: Boolean(sourceUrl.trim());
|
||||
const mediaSources = observationSources.filter(
|
||||
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
||||
);
|
||||
const recordedPerceptionSupported =
|
||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||
const recordedPerceptionEnabled =
|
||||
showDetections2d || showSegmentation || showCuboids3d;
|
||||
// The native recorded camera remains the authoritative original. Only 2D
|
||||
// image-space overlays need Rerun's paired camera/world composition; 3D
|
||||
// cuboids are added directly to the stable spatial view.
|
||||
const unifiedPerception = recordedPerceptionSupported &&
|
||||
(showDetections2d || showSegmentation);
|
||||
const livePerceptionAvailable = !recordedSource && streamActive;
|
||||
const detections2dActive = recordedSource
|
||||
? showDetections2d
|
||||
: livePerceptionLayers.detections2d;
|
||||
const segmentationActive = recordedSource
|
||||
? showSegmentation
|
||||
: livePerceptionLayers.segmentation;
|
||||
const cuboids3dActive = recordedSource
|
||||
? showCuboids3d
|
||||
: livePerceptionLayers.cuboids3d;
|
||||
const visibleMediaSources = mediaSources.filter((source) =>
|
||||
observationLayout.visibleSourceIds.has(source.id) &&
|
||||
!source.id.startsWith("recorded.perception."),
|
||||
);
|
||||
const initialRecordedPlaybackStartSeconds = recordedSource
|
||||
? mediaSources.reduce<number | undefined>((earliest, source) => {
|
||||
if (
|
||||
source.id.startsWith("recorded.perception.") ||
|
||||
source.delivery?.kind !== "recorded-fmp4-manifest"
|
||||
) return earliest;
|
||||
const start = source.delivery.timelineStartSeconds;
|
||||
return earliest === undefined ? start : Math.min(earliest, start);
|
||||
}, undefined)
|
||||
: undefined;
|
||||
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
|
||||
const pointCloudFocused = Boolean(
|
||||
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pointCloudFocused) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
observationLayout.setFocusedSourceId(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
|
||||
const floatingSourceMaximized = !unifiedPerception &&
|
||||
Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
|
||||
const presentedViewerStatus = intentionalSourceEnd
|
||||
? "idle"
|
||||
: rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
if (recordedSessionAdmission) {
|
||||
recordedSessionAdmission.reportSpatial(
|
||||
recordedSessionAdmission.key,
|
||||
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
|
||||
);
|
||||
}
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
(next: RerunPlaybackState | null) => setPlaybackState(next),
|
||||
[],
|
||||
);
|
||||
const onPlaybackControllerChange = useCallback(
|
||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||
[],
|
||||
);
|
||||
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
|
||||
setPerceptionLoad(next);
|
||||
if (next.phase === "unavailable" || next.phase === "error") {
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
}
|
||||
}, []);
|
||||
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
|
||||
setPointColorLoad(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPerceptionLoad({
|
||||
phase: recordedSource ? "loading" : "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
|
||||
});
|
||||
setPerceptionRetryGeneration(0);
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setRecordedViewResetGeneration(0);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [recordedSource, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
setPerceptionLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRequestedPlaybackSeconds.current = null;
|
||||
}, [sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!recordedSource
|
||||
|| !playbackController
|
||||
|| requestedPlaybackSeconds === null
|
||||
|| requestedPlaybackSeconds === undefined
|
||||
|| !Number.isFinite(requestedPlaybackSeconds)
|
||||
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
|
||||
) return;
|
||||
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
|
||||
playbackController.setPlaying(false);
|
||||
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
|
||||
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const publishViewportSize = () => {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (bounds.width < 1 || bounds.height < 1) return;
|
||||
observationLayout.setViewportSize({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
};
|
||||
publishViewportSize();
|
||||
const observer = new ResizeObserver(publishViewportSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[presentedViewerStatus];
|
||||
|
||||
const viewerStatusTone = presentedViewerStatus === "ready"
|
||||
? "success"
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
const rerunViewerProfile = recordedSource
|
||||
? recordedSessionRerunProfile({
|
||||
sourceUrl,
|
||||
artifact: recordedReplay,
|
||||
autoplayWhenReady: true,
|
||||
presentationGate: recordedSessionGate,
|
||||
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
|
||||
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
|
||||
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
|
||||
view: "spatial",
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: followRecordedTrajectory,
|
||||
perceptionLayers: {
|
||||
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
},
|
||||
perceptionRetryGeneration,
|
||||
lockPerceptionCameraInteraction: unifiedPerception,
|
||||
})
|
||||
: liveAcquisitionRerunProfile({
|
||||
sourceUrl,
|
||||
liveActivitySequence: livePresentationActivitySequence,
|
||||
liveStreamId: state?.spatialSource?.id ?? null,
|
||||
liveRecoveryAuthorityIdentity: streamActive
|
||||
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
|
||||
: null,
|
||||
});
|
||||
|
||||
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
|
||||
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
|
||||
toolbar={visualProfile?.toolbar ?? <> {recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
aria-pressed="true"
|
||||
disabled
|
||||
>
|
||||
Оригинал
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={detections2dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={detections2dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowDetections2d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
detections2d: !livePerceptionLayers.detections2d,
|
||||
})}
|
||||
>
|
||||
Объекты 2D
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={segmentationActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="image" />}
|
||||
aria-pressed={segmentationActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowSegmentation((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
segmentation: !livePerceptionLayers.segmentation,
|
||||
})}
|
||||
>
|
||||
Сегментация
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="apps" />}
|
||||
aria-pressed={cuboids3dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowCuboids3d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
cuboids3d: !livePerceptionLayers.cuboids3d,
|
||||
})}
|
||||
>
|
||||
Кубы 3D
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={followRecordedTrajectory ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={followRecordedTrajectory}
|
||||
title="Удерживать orbital-пивот на риге до повторного нажатия"
|
||||
onClick={() => setFollowRecordedTrajectory((current) => !current)}
|
||||
>
|
||||
Следовать
|
||||
</Button>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
|
||||
>
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<SpatialToolbarActions openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={visualProfile?.renderer ?? <> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||
onPointColorLoadChange={onPointColorLoadChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackControllerChange={onPlaybackControllerChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
</>}
|
||||
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
|
||||
model={spatialControls.model}
|
||||
spatialActivity={visualProfile?.activity}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene", launchProfile),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>):null}
|
||||
sourceControls={visualProfile?.sourceControls ?? <> {pointCloudFocused ? (
|
||||
<IconButton
|
||||
className="scene-focus-exit"
|
||||
label="Выйти из полноэкранного режима облака точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(null)}
|
||||
>
|
||||
<Icon name="minimize" size={16} />
|
||||
</IconButton>
|
||||
) : !floatingSourceMaximized ? (
|
||||
<div className="scene-source-controls">
|
||||
<ObservationSourcePicker
|
||||
sources={unifiedPerception
|
||||
? observationSources.filter((source) => source.modality === "point-cloud")
|
||||
: observationSources}
|
||||
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||
pendingSourceIds={observationLayout.pendingSourceIds}
|
||||
onToggle={observationLayout.toggleSource}
|
||||
/>
|
||||
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && (visualProfile || sourceUrl.trim()) ? (
|
||||
<IconButton
|
||||
className="scene-source-control"
|
||||
label="Развернуть облако точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
|
||||
>
|
||||
<Icon name="expand" size={16} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>}
|
||||
status={visualProfile?.status ?? {label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
|
||||
metrics={visualProfile?.metrics ?? <> <div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Точек</span>
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
{streamActive ? (
|
||||
<div>
|
||||
<span>AI</span>
|
||||
<strong>
|
||||
{aiLatency === null ? "—" : formatNumber(aiLatency)}
|
||||
<small>{aiLatency === null ? "" : " мс"}</small>
|
||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}</>} overlays={<> {!visualProfile && !pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!floatingSourceMaximized && recordedSource && (
|
||||
perceptionLoad.phase === "loading" ||
|
||||
perceptionLoad.phase === "error" ||
|
||||
pointColorLoad.phase === "loading" ||
|
||||
pointColorLoad.phase === "error"
|
||||
) ? (
|
||||
<div className="scene-operation-status-stack">
|
||||
{perceptionLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{perceptionLoad.phase === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-operation-status scene-operation-status--action"
|
||||
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
|
||||
>
|
||||
<Icon name="refresh" size={12} />
|
||||
<span>Повторить AI</span>
|
||||
</button>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "error" ? (
|
||||
<div className="scene-operation-status scene-operation-status--error" role="status">
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</>}
|
||||
timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
seekable={recordedSource && playbackState?.rangeNs
|
||||
? true
|
||||
: timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
rangeNs={playbackState?.rangeNs}
|
||||
currentNs={playbackState?.currentNs}
|
||||
playing={playbackState?.playing}
|
||||
onSeek={playbackController?.seek}
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
accumulationMaxSeconds={sceneSettings.accumulationMaxSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
</>}
|
||||
media={<>{visibleMediaSources.length === 0 ? visualProfile?.mediaFallback : null} {visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
count={visibleMediaSources.length}
|
||||
boundsRef={viewportRef}
|
||||
rect={observationLayout.windowRects[source.id]}
|
||||
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||
active={observationLayout.activeFloatingSourceId === source.id}
|
||||
hidden={pointCloudFocused || unifiedPerception}
|
||||
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
|
||||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
playback={recordedSource && playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: playbackState.playing,
|
||||
} : null}
|
||||
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
onClose={() => {
|
||||
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||
observationLayout.setFloatingMaximized(source.id, false);
|
||||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{visualProfile?.tools?.(viewportRef)}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
!observationLayout.visibleSourceIds.has(source.id) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
playback={playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: false,
|
||||
} : null}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}</>} footer={visualProfile?.footer ?? <div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
|
||||
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>}/>;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
||||
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,series,segments,frames,localPreview,MEDIA_PROTOCOL;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({monitorSeries:series}=await server.ssrLoadModule('/src/core/fleet/boardMonitor.ts'));
|
||||
({lineSegments:segments}=await server.ssrLoadModule('/src/components/system/TelemetrySeries.tsx'));
|
||||
({MEDIA_PROTOCOL}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts'));
|
||||
({localPreviewFrames:frames,localPreview}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/localPreview.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('archive gaps break the plot instead of manufacturing zero or connecting missing time',()=>{
|
||||
const values=series({start:0,end:20,bucket_seconds:5,series:[{at:0,max:80},{at:5,max:90},{at:20,max:10}]},'max');
|
||||
assert.deepEqual(values,[80,90,null,null,10]);
|
||||
const result=segments(values,100);
|
||||
assert.equal(result.length,2);
|
||||
assert.match(result[0],/^0.00,8.80 25.00,5.40$/);
|
||||
assert.deepEqual(segments([null,NaN,null]),[]);
|
||||
assert.notEqual(segments([-10,0,10])[0].split(' ')[0].split(',')[1],segments([-10,0,10])[0].split(' ')[1].split(',')[1]);
|
||||
});
|
||||
test('HTTP media framing survives every byte boundary and refuses oversized frames',()=>{
|
||||
const output=[],receive=frames((kind,bytes)=>output.push([kind,[...bytes]]));
|
||||
const encoded=Uint8Array.of(2,0,0,0,3,4,5,6,3,0,0,0,2,7,8);
|
||||
for(const byte of encoded)receive(Uint8Array.of(byte));
|
||||
assert.deepEqual(output,[[2,[4,5,6]],[3,[7,8]]]);
|
||||
assert.throws(()=>frames(()=>{})(Uint8Array.of(2,0,1,0,0)));
|
||||
assert.throws(()=>frames(()=>{})(Uint8Array.of(7,0,0,0,0)));
|
||||
});
|
||||
|
||||
test('local carrier delivers registered channels and ACKs through completed responses',async()=>{
|
||||
const encode=(kind,data)=>{const bytes=typeof data==='string'?new TextEncoder().encode(data):Uint8Array.from(data);const out=new Uint8Array(bytes.length+5);out[0]=kind;new DataView(out.buffer).setUint32(1,bytes.length);out.set(bytes,5);return out;};
|
||||
const peer='peer_'+ 'a'.repeat(32), received=[], closed=[];let preview;
|
||||
const device={id:'synthetic-k1',snapshot:{context:{session_id:'synthetic-session'}}};
|
||||
const transport={
|
||||
submit:async request=>{closed.push(request);return {state:'complete'};},
|
||||
localPreview:{
|
||||
open:async request=>{assert.equal(request.action_id,'offer');return new Response(encode(0,JSON.stringify({peer_id:peer,media_protocol:MEDIA_PROTOCOL})));},
|
||||
read:async(id,after)=>{
|
||||
assert.equal(id,peer);
|
||||
if(after===0)return new Response(new Uint8Array([...encode(2,[8,9]),...encode(3,'camera-ready')]));
|
||||
assert.equal(after,7);preview.close();return new Response();
|
||||
}
|
||||
}
|
||||
};
|
||||
preview=localPreview(device,transport,{},()=>assert.fail('carrier failed'));
|
||||
preview.rrd.onmessage=event=>{received.push([...new Uint8Array(event.data)]);preview.rrd.send(JSON.stringify({ack:7}));};
|
||||
preview.camera.onmessage=event=>received.push(event.data);
|
||||
await preview.start();
|
||||
assert.deepEqual(received,[[8,9],'camera-ready']);
|
||||
assert.equal(preview.rrd.readyState,'closed');assert.equal(closed.length,1);
|
||||
assert.equal(closed[0].action_id,'close-peer');assert.equal(closed[0].parameters.peer_id,peer);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,layout,source,fanout;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts');
|
||||
source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts');
|
||||
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('layout keeps independent identities and restores absent devices without confusing equal model names',()=>{
|
||||
const a=source.observationKey('camera-1','left'),b=source.observationKey('camera-2','left'),c=source.observationKey('camera-1','right');
|
||||
assert.notEqual(a,b);assert.notEqual(a,c);
|
||||
const value={...layout.emptyObservationLayout(),order:[b,a],hidden:[a],layers:{[a]:'depth'}};
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,c]),[a,c]);
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,b,c]),[b,a,c]);
|
||||
assert.deepEqual(layout.moveObservation(value,[a,b,c],c,b).order,[c,b,a]);
|
||||
assert.deepEqual(value.hidden,[a]);
|
||||
});
|
||||
test('untrusted browser layout is bounded and validates revisions and split numbers',()=>{
|
||||
const decoded=layout.decodeObservationLayout({version:1,order:['a','a',null,8],hidden:'all',layers:{a:'depth',b:[]},splits:{a:Infinity,b:-8,c:98},arrangement:'bad'});
|
||||
assert.deepEqual(decoded.order,['a']);assert.deepEqual(decoded.hidden,[]);assert.deepEqual(decoded.splits,{b:20,c:80});assert.deepEqual(decoded.layers,{a:'depth'});assert.equal(decoded.arrangement,'auto');
|
||||
assert.deepEqual(layout.decodeObservationLayout({version:2}),layout.emptyObservationLayout());
|
||||
});
|
||||
test('camera observation exposes only advertised layers with one viewport and RGB default',()=>{
|
||||
const views=source.cameraObservationViews({name:'RealSense',layers:['depth','private-channel','color','points']});
|
||||
assert.equal(views.length,1);assert.deepEqual(views[0].layers.map(v=>v.value),['color','depth','points']);
|
||||
assert.deepEqual(source.cameraObservationViews({name:'unknown',layers:[]}),[]);
|
||||
});
|
||||
test('two Rerun views share bytes, apply independent blueprints once and dispose both hosts',async()=>{
|
||||
const fetchBefore=globalThis.fetch;globalThis.fetch=async url=>new Response(Uint8Array.of(String(url).includes('plan')?2:1));
|
||||
const sent=[[],[]],closed=[],started=[];let count=0;
|
||||
const factory=mount=>{const index=count++;return {ready:Promise.resolve({mount,viewer:{start:async()=>started.push(index),open_channel:name=>({ready:true,send_rrd:bytes=>sent[index].push([name,[...bytes]]),close:()=>{}}),get_active_recording_id:()=> 'recording-1',override_panel_state:()=>{},set_active_timeline:()=>{}}}),dispose:()=>closed.push(index)};};
|
||||
try{
|
||||
const host=fanout(factory,{current:{id:'top'}})({id:'3d'});const {viewer,mount}=await host.ready;
|
||||
await viewer.start(null,mount,{});const channel=viewer.open_channel('rrd');channel.send_rrd(Uint8Array.of(9,8));
|
||||
assert.deepEqual(sent[0],[['rrd',[9,8]]]);assert.deepEqual(sent[1],[['rrd',[9,8]]]);
|
||||
viewer.get_active_recording_id();viewer.get_active_recording_id();
|
||||
assert.deepEqual(sent[0].at(-1),['observation-perspective',[1]]);assert.deepEqual(sent[1].at(-1),['observation-plan',[2]]);
|
||||
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
||||
}finally{globalThis.fetch=fetchBefore;}
|
||||
});
|
||||
@@ -1676,7 +1676,7 @@ test("physical-command guidance projects backend policy without exposing reason
|
||||
);
|
||||
});
|
||||
|
||||
test("restart-recovery guidance describes automatic recovery without protocol ceremony", () => {
|
||||
test("restart-recovery guidance offers an explicit read-only recovery", () => {
|
||||
const deniedFresh = {
|
||||
connection_policy: {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
@@ -1712,7 +1712,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
||||
);
|
||||
assert.deepEqual(durableGuidance, {
|
||||
reason: "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
|
||||
nextAction: "Дождитесь автоматического восстановления сохранённого подключения K1.",
|
||||
nextAction: "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
`${durableGuidance.reason} ${durableGuidance.nextAction}`,
|
||||
@@ -1725,7 +1725,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
||||
deniedFresh,
|
||||
"observe-fresh-device-network",
|
||||
).nextAction,
|
||||
"Дождитесь автоматического восстановления связи с тем же K1.",
|
||||
"Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1991,7 +1991,7 @@ test("observed SCANNING recovery is a successful STOP-only connection outcome",
|
||||
);
|
||||
assert.ok(
|
||||
acquisitionSource.indexOf("terminalPhysicalStopObserved ? (")
|
||||
< acquisitionSource.indexOf("<div className=\"scan-configuration-grid\">"),
|
||||
< acquisitionSource.indexOf("<K1AcquisitionFields"),
|
||||
"recovered SCANNING must render the STOP-only branch before project/START controls",
|
||||
);
|
||||
});
|
||||
@@ -3461,7 +3461,7 @@ test("each terminal explicit provisioning click starts a fresh operation identit
|
||||
|
||||
assert.match(
|
||||
connectRecovery,
|
||||
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
|
||||
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
|
||||
);
|
||||
assert.match(
|
||||
connectRecovery,
|
||||
@@ -4009,7 +4009,7 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
|
||||
|
||||
@@ -4024,8 +4024,8 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
||||
});
|
||||
assert.match(attMessage, /ATT 4 INVALID_PDU/);
|
||||
assert.match(attMessage, /Автоматический повтор команды K1 не отправлялся/);
|
||||
assert.match(attMessage, /Сессия подключения в интерфейсе сброшена/);
|
||||
assert.match(attMessage, /новый поиск Bluetooth, выберите K1/);
|
||||
assert.match(attMessage, /Проверьте состояние K1 через «Переподключиться»/);
|
||||
assert.match(attMessage, /новое подключение через поиск Bluetooth/);
|
||||
|
||||
const preWriteAttMessage = networkProvisionFailureMessage({
|
||||
action: "network.provision",
|
||||
@@ -4059,7 +4059,7 @@ test("post-dispatch ambiguity resets the UI session without an automatic retry",
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(message, /ручн|read-only|защитный барьер/i);
|
||||
});
|
||||
@@ -4247,7 +4247,7 @@ test("host Wi-Fi failures close the attempt and require a fresh explicit connect
|
||||
|
||||
assert.equal(
|
||||
operationTimeout,
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
|
||||
assert.match(missingNetwork, /K1 принял команду Quick Connect/);
|
||||
|
||||
@@ -191,11 +191,13 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
assert.match(spatialControls, /role="alert"/);
|
||||
assert.doesNotMatch(spatialControls, /Повторить остановку/);
|
||||
assert.match(spatialControls, /stopLocalReceiver/);
|
||||
assert.match(spatialControls, /<ActivityIndicator size="compact"/);
|
||||
assert.match(spatialControls, /aria-busy=\{phase\.busy\}/);
|
||||
assert.match(spatialControls, /K1 калибруется и готовит облако точек/);
|
||||
assert.match(spatialControls, /Первые данные могут появиться через десятки секунд/);
|
||||
assert.match(spatialControls, /Не перемещайте устройство/);
|
||||
assert.match(spatialControls, /<K1SpatialSession/);
|
||||
const spatialSession = readFileSync(join(pluginFrontendRoot, "components/K1SpatialSession.tsx"), "utf8");
|
||||
assert.match(spatialSession, /<ActivityIndicator size="compact"/);
|
||||
assert.match(spatialSession, /aria-busy=\{phase\.busy\}/);
|
||||
assert.match(spatialSession, /K1 калибруется и готовит облако точек/);
|
||||
assert.match(spatialSession, /Первые данные могут появиться через десятки секунд/);
|
||||
assert.match(spatialSession, /Не перемещайте устройство/);
|
||||
|
||||
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
||||
assert.doesNotMatch(styles, /@keyframes xgrids-k1-spin/);
|
||||
@@ -218,8 +220,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply",
|
||||
);
|
||||
assert.match(provisioning, /scanSecondsRemaining/);
|
||||
assert.match(provisioning, /setInterval\(updateCountdown, 250\)/);
|
||||
assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/);
|
||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
||||
assert.match(provisioning, /Поиск Bluetooth · до \{scanSecondsRemaining \?\? BLE_DISCOVERY_TIMEOUT_SECONDS\} с/);
|
||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
@@ -258,7 +260,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply",
|
||||
assert.doesNotMatch(provisioning, /allow_host_wifi_switch/);
|
||||
assert.doesNotMatch(provisioning, /(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i);
|
||||
for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
|
||||
assert.match(provisioning, new RegExp(`<${sharedControl}\\b`), sharedControl);
|
||||
const password = readFileSync(join(pluginFrontendRoot, "components/K1WifiPasswordField.tsx"), "utf8");
|
||||
assert.match(provisioning + password, new RegExp(`<${sharedControl}\\b`), sharedControl);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
@@ -284,7 +287,7 @@ test("K1 click-owned actions are fenced without hidden frontend continuations",
|
||||
provisioning.indexOf("const verifyAppliedNetwork"),
|
||||
);
|
||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||
assert.match(search, /durationSeconds:\s*6/);
|
||||
assert.match(search, /durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(
|
||||
apply,
|
||||
@@ -680,7 +683,7 @@ test("automatic K1 live start opens the selected delivered camera despite an old
|
||||
assert.match(layout, /source\.capabilities\.overlay/);
|
||||
});
|
||||
|
||||
test("K1 connect errors reset the UI session without exposing reconciliation ceremony", () => {
|
||||
test("K1 connect errors preserve the result and expose explicit recovery", () => {
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
@@ -691,15 +694,17 @@ test("K1 connect errors reset the UI session without exposing reconciliation cer
|
||||
assert.notEqual(connectEnd, -1);
|
||||
const connectFlow = runtime.slice(connectStart, connectEnd);
|
||||
|
||||
assert.match(connectFlow, /operationByIdempotencyKey\(/);
|
||||
assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/);
|
||||
assert.match(connectFlow, /resetConnectSessionMessage\(/);
|
||||
assert.match(connectFlow, /observeProvisioningRequest\(/);
|
||||
assert.match(connectFlow, /operation\?\.status !== "succeeded"/);
|
||||
assert.match(connectFlow, /networkProvisionFailureMessage\(/);
|
||||
assert.doesNotMatch(
|
||||
connectFlow,
|
||||
/требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
|
||||
);
|
||||
assert.match(runtime, /Сессия подключения в интерфейсе сброшена/);
|
||||
assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/);
|
||||
const networkFlow = readFileSync(join(pluginFrontendRoot, "networkProvisioning.ts"), "utf8");
|
||||
assert.match(networkFlow, /Проверьте состояние K1 через «Переподключиться»/);
|
||||
assert.match(networkFlow, /новое подключение через поиск Bluetooth/);
|
||||
assert.equal((networkFlow.match(/await context\.send\(/g) ?? []).length, 1);
|
||||
const verifyStart = runtime.indexOf("const verifyConnection = useCallback(");
|
||||
const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
|
||||
const verifyFlow = runtime.slice(verifyStart, verifyEnd);
|
||||
@@ -729,10 +734,10 @@ test("K1 provisioning keeps the operator draft separate from the backend lease",
|
||||
assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
|
||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
|
||||
/<K1WifiPasswordField[\s\S]*?onChange=\{setPassword\}/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ test("default environment exposes every current product page", () => {
|
||||
"system",
|
||||
"polygon",
|
||||
]);
|
||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health");
|
||||
assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene");
|
||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "vehicles");
|
||||
assert.equal(defaults.pages.observation.primaryWorkspaceId, "cameras");
|
||||
});
|
||||
|
||||
test("server document decodes and re-encodes the complete page contract", () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../plugins/insta360-x4/frontend/src/model.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {cameraLabel,cameraStatus,resolutionLabel,cameraRowActions}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const camera={id:'instax4_synthetic_a',online:true,configured:true,prepared:true,snapshot:{enrollment:'enrolled'},camera_status:{connected:true,recording:0,preview:0,function_mode:7}};
|
||||
|
||||
test('SD recording remains visible after preview closes, independently for each camera',()=>{
|
||||
const recording={...camera,camera_status:{...camera.camera_status,recording:1}};
|
||||
assert.equal(cameraLabel(recording,true).label,'Запись на карту камеры');
|
||||
assert.equal(cameraLabel({...camera,id:'instax4_synthetic_b'},true).label,'Подключена');
|
||||
assert.equal(cameraStatus(recording).preview,0);
|
||||
});
|
||||
test('stale and unconfirmed camera state never appears ready or actively recording',()=>{
|
||||
assert.equal(cameraLabel({...camera,camera_status:{...camera.camera_status,recording:1}},false).tone,'neutral');
|
||||
assert.equal(cameraLabel({...camera,camera_status:{}},true).tone,'warning');
|
||||
assert.equal(cameraLabel({...camera,camera_status:{...camera.camera_status,preview:-1}},true).tone,'warning');
|
||||
assert.equal(cameraStatus({...camera,camera_status:{preview:true,recording:'1',connected:1}}).preview,null);
|
||||
});
|
||||
test('installed model support does not enroll a new physical camera',()=>{
|
||||
assert.equal(cameraLabel({...camera,configured:false},true).label,'Требуется подготовка');
|
||||
assert.equal(cameraLabel({...camera,initializable:false},true).tone,'warning');
|
||||
});
|
||||
test('SDK resolution names are presented with their actual dimensions and frame rate',()=>{
|
||||
assert.equal(resolutionLabel('3840_1920_30'),'3840 × 1920 · 30 кадр/с');
|
||||
});
|
||||
|
||||
test('manual wake is available for an enrolled offline camera with automatic recovery disabled',()=>{
|
||||
const offline={...camera,online:false,camera_status:{recovery:{supported:true,enabled:false,wake_available:true}}};
|
||||
assert.equal(cameraRowActions(offline)[0].actionId,'power.wake');
|
||||
assert.equal(cameraRowActions(offline)[0].disabled,false);
|
||||
assert.deepEqual(cameraRowActions({...offline,online:true}),[]);
|
||||
assert.equal(cameraRowActions({...offline,configured:false})[0].disabled,true);
|
||||
assert.equal(cameraRowActions({...offline,prepared:false})[0].disabled,true);
|
||||
assert.equal(offline.camera_status.recovery.enabled,false);
|
||||
});
|
||||
test('old drivers and unknown wake identities never offer an active power command',()=>{
|
||||
assert.deepEqual(cameraRowActions(camera),[]);
|
||||
assert.equal(cameraRowActions({...camera,online:false,camera_status:{recovery:{supported:true}}})[0].disabled,true);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
let server,Detail,presentation,enrolledDevice;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({K1Detail:Detail}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/K1Detail.tsx'));
|
||||
presentation=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/presentation.ts');
|
||||
({enrolledDevice}=await server.ssrLoadModule('../../packages/sensor-ui/src/contracts.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const device={id:'synthetic',kind:'k1',name:'K1 test',online:true,verified:true,prepared:true,
|
||||
control:{can_start:true,can_stop:false,can_verify:true,generation:1,revision:1,acquisition_id:null},
|
||||
snapshot:{context:{session_id:'exact-session',execution:{node_id:'test'}},acquisition:'idle'}};
|
||||
const render=(value,enabled=true)=>renderToStaticMarkup(createElement(Detail,{device:value,enabled,
|
||||
transport:{},back:()=>{},refresh:async()=>{},failure:()=>{},createRerunHost:()=>assert.fail('SSR must not start a viewer or hardware')}));
|
||||
|
||||
test('manual K1 surface admits one START only after current control proof',()=>{
|
||||
const markup=render(device);
|
||||
assert.match(markup,/Инициировать запуск/);
|
||||
const start=(markup.match(/<button\b[\s\S]*?<\/button>/g)??[]).filter(button=>button.replace(/<[^>]+>/g,'')==='Инициировать запуск');
|
||||
assert.equal(start.length,1);
|
||||
assert.match(start[0],/<button[^>]*disabled=""/);
|
||||
for(const label of ['Тип установки / носитель','Режим GNSS','Название проекта'])assert.ok(markup.includes(label));
|
||||
assert.match(markup,/Настройки устройства/);
|
||||
assert.doesNotMatch(markup,/Остановить устройство|Обновить просмотр|sensor-live-layout/);
|
||||
const waiting={...device,online:false,verified:false,control:{...device.control,can_start:false,network_applied:true,reason_code:'application_authority_unavailable'}};
|
||||
const pending=render(waiting);
|
||||
assert.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/);
|
||||
assert.match(pending,/Проверить состояние K1/);
|
||||
assert.match(pending,/авторизовать/);
|
||||
assert.equal(presentation.k1Status(waiting,true).label,'Нет связи с K1');
|
||||
assert.equal(presentation.k1ManualState(device,false).canStart,false);
|
||||
});
|
||||
test('active acquisition exposes STOP and Rerun without another START',()=>{
|
||||
const active={...device,control:{...device.control,can_start:false,can_stop:true},snapshot:{...device.snapshot,acquisition:'streaming'}};
|
||||
const markup=render(active);
|
||||
assert.match(markup,/Остановить устройство/);
|
||||
assert.match(markup,/rerun-viewport__canvas/);
|
||||
assert.doesNotMatch(markup,/>Статус</);
|
||||
assert.match(markup,/>Пространственная сцена</);
|
||||
for(const label of ['Движок','Слои','Отображение','Камера K1','Окно накопления облака точек'])assert.ok(markup.includes(label));
|
||||
assert.ok(markup.includes('data-presented="false"'));
|
||||
assert.ok(!markup.includes('Loading Rerun'));
|
||||
assert.ok(!markup.includes('The data layer'));
|
||||
assert.ok(!markup.includes('data-align="center"'));
|
||||
|
||||
assert.doesNotMatch(markup,/Восстановить просмотр|nodedc-activity-indicator/);
|
||||
assert.doesNotMatch(markup,/Инициировать запуск|Обновить просмотр/);
|
||||
assert.equal(presentation.k1ManualState(active,false).canStart,false);
|
||||
assert.equal(presentation.k1ManualState(active,false).showStop,true);
|
||||
});
|
||||
test('calibration loader stays inside the primary action',()=>{
|
||||
const starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}};
|
||||
const markup=render(starting);
|
||||
const start=(markup.match(/<button\b[\s\S]*?<\/button>/g)??[]).filter(button=>button.replace(/<[^>]+>/g,'')==='Запускаем устройство');
|
||||
assert.equal(start.length,1);
|
||||
assert.match(start[0],/<button[^>]*aria-busy="true"/);
|
||||
assert.equal((start[0].match(/class="nodedc-activity-indicator"/g)??[]).length,1);
|
||||
assert.match(markup,/K1 калибруется и готовит облако точек/);
|
||||
assert.doesNotMatch(markup,/Обновить просмотр/);
|
||||
});
|
||||
test('connection loss is visible independently of completed STOP',()=>{
|
||||
const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}};
|
||||
const markup=render(stopped);
|
||||
assert.match(markup,/Нет связи с K1/);
|
||||
assert.match(markup,/Проверить состояние K1/);
|
||||
assert.doesNotMatch(markup,/Инициировать запуск|нет управления/);
|
||||
});
|
||||
test('enrollment handoff opens only the verified current session',()=>{
|
||||
const inventory={fresh:true,items:[device],operations:[]};
|
||||
assert.equal(enrolledDevice(inventory,'exact-session'),device);
|
||||
assert.equal(enrolledDevice(inventory,'other-session'),null);
|
||||
assert.equal(enrolledDevice({...inventory,fresh:false},'exact-session'),null);
|
||||
assert.equal(enrolledDevice({...inventory,items:[{...device,verified:false}]},'exact-session'),null);
|
||||
assert.equal(enrolledDevice({...inventory,items:[device,device]},'exact-session'),null);
|
||||
});
|
||||
|
||||
test('completed scan with healthy control remains connected and permits a named next scan',()=>{
|
||||
const stopped={...device,control:{...device.control,phase:'completed'}};
|
||||
const markup=render(stopped);
|
||||
assert.equal(presentation.k1Status(stopped,true).label,'Подключён');
|
||||
assert.equal(presentation.k1ManualState(stopped,true).canStart,true);
|
||||
assert.match(markup,/Инициировать запуск/);
|
||||
assert.doesNotMatch(markup,/Проверить состояние K1|Подключить устройство|Устройство остановлено/);
|
||||
assert.equal(presentation.k1Status(stopped,false).tone,'neutral');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user