Author SHA1 Message Date
DCCONSTRUCTIONS 76dc9f19c9 chore: consolidate superseded experiment history into main
History-only merge: retain the original Node worktree snapshot 1c7dd29 without reverting its canonicalized implementation (63ea2be and subsequent main fixes). RAV004 commits 45019f8/cd16ea2/88e47fc and Gaussian commits 2ccf172/2e44e27 are patch-equivalent to commits already in main (verified with git cherry). The current source tree is intentionally unchanged; old snapshots remain reachable for audit.
2026-09-21 08:48:14 +03:00
DCCONSTRUCTIONS e515ab1b8c feat(planning): consolidate recorded-route localization and spatial scene
Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
2026-09-21 08:47:19 +03:00
DCCONSTRUCTIONS 1c7dd29d8a chore(node): preserve pre-canonicalization experiment snapshot
Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
2026-09-21 08:45:34 +03:00
DCCONSTRUCTIONS be58d589e2 feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
2026-09-10 22:12:05 +03:00
DCCONSTRUCTIONS 5f75afb720 docs(node): record current handoff status and X4 acceptance boundaries 2026-09-10 16:39:29 +03:00
DCCONSTRUCTIONS 99f29218d7 feat(x4): add per-camera manual wake and hide power action when connected 2026-09-10 16:27:48 +03:00
DCCONSTRUCTIONS db14ee5435 Record repeatable X4 reconnect and prepare recovery cancellation test 2026-09-10 14:36:07 +03:00
DCCONSTRUCTIONS 81dd44906d Record first X4 USB reconnect and automatic preview acceptance 2026-09-10 14:28:34 +03:00
DCCONSTRUCTIONS d45b329de8 Add packaged per-camera X4 wake and preview recovery 2026-09-10 14:20:52 +03:00
DCCONSTRUCTIONS f164606828 Add bounded X4 Bluetooth wake and record Android recovery proof 2026-09-10 12:48:09 +03:00
DCCONSTRUCTIONS 556e376a62 Record X4 wake by short Power with USB retained and BLE discovery 2026-09-10 12:08:01 +03:00
DCCONSTRUCTIONS 1a2de8df65 Package bounded X4 BLE diagnostics and USB mode reader 2026-09-10 11:59:00 +03:00
DCCONSTRUCTIONS b536f0049a Document X4 Android reconnect protocol research and recovery options 2026-09-10 11:25:07 +03:00
DCCONSTRUCTIONS 3b85299fd9 Document X4 USB power limits from root hub diagnostics 2026-09-10 11:04:04 +03:00
DCCONSTRUCTIONS 505844adc3 Record failed X4 USB reconnect with preview stopped 2026-09-10 10:44:27 +03:00
DCCONSTRUCTIONS 89b0eeca3f Record automatic X4 USB mode and two-camera viewing confirmation 2026-09-10 10:27:40 +03:00
DCCONSTRUCTIONS 1488a46794 Document X4 USB mode recovery and verified video after restart 2026-09-10 10:09:29 +03:00
DCCONSTRUCTIONS e4339f4e88 Record B11 hardware acceptance and X4 USB reconnect failure 2026-09-10 09:47:34 +03:00
DCCONSTRUCTIONS 7e257275ab Record X4 checkpoint and Ops implementation report
Link the committed Core/Design Guideline pair to MISSIONCOR-77 and preserve
MISSIONCOR-76 baseline through an appended report. Record successful MCP
readback, qualified source comparison and remaining hardware acceptance.
2026-09-10 09:25:12 +03:00
DCCONSTRUCTIONS a3c15e11e9 Add packaged Insta360 X4 integration and recover paired Node channels
Discover independent camera instances and prepare their versioned runtime
from Node or remote Core. Add isolated SDK workers, camera controls, raw
dual-fisheye WebRTC preview, and shared action/region loading states.

Recover existing Node bindings over known Tailscale addresses after a Core
LAN address change. Preserve identities and trust, pin both peers, migrate
endpoints with revision checks, and require real heartbeats for online status.
Fix the Python client certificate profile for Go X509 verification.

Pin Design Guideline 8c53f73 and retain installer/build/acceptance history.
Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending.

Validation: qualified DG/Node builds and Go race tests; 31 fleet tests;
Python-to-Go certificate interoperability and live tailnet recovery with five
fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance.
Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
2026-09-10 09:21:24 +03:00
DCCONSTRUCTIONS 54a85fdf50 Record installed R19 recovery and onboard K1 acceptance 2026-09-08 11:01:59 +03:00
DCCONSTRUCTIONS 14f8375713 Repair monitor cluster upgrade without blocking K1 package configuration 2026-09-08 10:44:27 +03:00
DCCONSTRUCTIONS 6492ebcdee Record R18 evidence and retain signed telemetry values in plots 2026-09-08 01:53:30 +03:00
DCCONSTRUCTIONS e57511f975 Keep K1 runtime payload manifest in canonical order 2026-09-08 01:41:27 +03:00
DCCONSTRUCTIONS f0802d2713 Fix onboard WebKit preview and archive board telemetry locally 2026-09-08 01:37:52 +03:00
DCCONSTRUCTIONS d8afb61229 Record successful Node R17 installation on owner Ubuntu board 2026-09-07 23:28:46 +03:00
DCCONSTRUCTIONS 421a8c515a Record R17 package validation and pending onboard transfer 2026-09-07 23:03:11 +03:00
DCCONSTRUCTIONS a23c5b2005 Reuse canonical home and settings on Node and admit local K1 viewer 2026-09-07 22:55:13 +03:00
DCCONSTRUCTIONS 1832943558 Record verified Ops publication of onboard K1 laboratory checkpoint 2026-09-07 21:50:38 +03:00
DCCONSTRUCTIONS cc90709919 Record owner-confirmed onboard K1 Bridge laboratory checkpoint 2026-09-07 21:43:19 +03:00
DCCONSTRUCTIONS ee5d760c30 Verify R16 onboard installation and service health 2026-09-07 21:27:40 +03:00
DCCONSTRUCTIONS 0e9faa6824 Record inspected R16 artifacts and pending onboard acceptance 2026-09-07 21:22:41 +03:00
DCCONSTRUCTIONS c6693d6f44 Retain K1 connection between scans and admit the next named acquisition 2026-09-07 21:13:17 +03:00
DCCONSTRUCTIONS ed9a77ba73 Record verified R15 installation after owner restart 2026-09-07 20:16:42 +03:00
DCCONSTRUCTIONS 7dd2c1c836 Record verified R15 packages and pending onboard acceptance 2026-09-07 20:13:37 +03:00
DCCONSTRUCTIONS 762d77ef95 Unify K1 discovery ownership and verification across enrollment paths 2026-09-07 20:04:46 +03:00
DCCONSTRUCTIONS 9c4d70b0e9 Record R14 public artifacts and staged installation scope 2026-09-07 19:04:52 +03:00
DCCONSTRUCTIONS eed53dbc0b Fix onboard K1 enrollment continuity and share named acquisition preparation 2026-09-07 18:53:38 +03:00
DCCONSTRUCTIONS 4f333fc11c Record verified R13 installation on onboard computer 2026-09-07 17:59:36 +03:00
DCCONSTRUCTIONS 35a517f921 Record R13 artifact provenance and pending installation acceptance 2026-09-07 17:54:13 +03:00
DCCONSTRUCTIONS 9bba44f7c4 Preserve onboard preview recordings across backpressure and share the complete spatial scene 2026-09-07 17:47:10 +03:00
DCCONSTRUCTIONS 9b6534287a Record R12 update artifacts and pending clean-cache acceptance 2026-09-07 16:53:32 +03:00
DCCONSTRUCTIONS 9e03404f23 Admit onboard camera preview only after its acquisition producer is ready 2026-09-07 16:44:38 +03:00
DCCONSTRUCTIONS fa8ac760a1 Share the K1 live scene template and preserve idle media channels 2026-09-07 16:38:39 +03:00
DCCONSTRUCTIONS 309bdf759e Confirm installed K1 R11 runtime and authority availability 2026-09-07 15:57:53 +03:00
DCCONSTRUCTIONS ff00851216 Record staged K1 R11 artifact and acceptance gate 2026-09-07 15:54:38 +03:00
DCCONSTRUCTIONS ad24518b71 Refresh selected BlueZ path before K1 GATT operations 2026-09-07 15:50:06 +03:00
DCCONSTRUCTIONS 7ce09eabcd Record R10 release hashes and remaining acceptance gates 2026-09-07 15:32:56 +03:00
DCCONSTRUCTIONS 92b60de945 Preserve RRD preview frames and clarify onboard K1 status 2026-09-07 15:27:13 +03:00
DCCONSTRUCTIONS 67398fef10 Record staged R9 packages and pending installed acceptance 2026-09-07 14:55:28 +03:00
DCCONSTRUCTIONS a93f1f1b8b Accept systemd credential delivery and clarify manual K1 control 2026-09-07 14:44:46 +03:00
DCCONSTRUCTIONS 102e93796b Record staged K1 causal diagnostics release R8 2026-09-07 14:16:18 +03:00
DCCONSTRUCTIONS 4f715e41d2 Preserve native K1 error causes and distinguish Wi-Fi actions 2026-09-07 14:11:34 +03:00
DCCONSTRUCTIONS 8f17a7e282 Confirm installed Linux route contract fix R7 2026-09-07 13:55:50 +03:00
DCCONSTRUCTIONS 7187fd86bd Record prepared Linux route contract fix release R7 2026-09-07 13:53:03 +03:00
DCCONSTRUCTIONS e6521e855a Fix Linux route probe contract and expose K1 verification after Wi-Fi apply 2026-09-07 13:46:01 +03:00
DCCONSTRUCTIONS 06d216ac4a Record installed wireless steps and diagnostic release R6 2026-09-07 13:35:25 +03:00
DCCONSTRUCTIONS d7986cb62f Group onboard wireless steps and trace journalled Bluetooth failures 2026-09-07 13:27:21 +03:00
DCCONSTRUCTIONS a0732386f1 docs(node): record R5 package and pending Ubuntu authentication 2026-09-07 13:08:01 +03:00
DCCONSTRUCTIONS 5c4546a2a6 fix(node): clarify wireless enrollment and restore active K1 after upgrade 2026-09-07 13:02:28 +03:00
DCCONSTRUCTIONS ef1c1f50de docs(k1): record successful onboard Bluetooth discovery 2026-09-07 12:46:14 +03:00
DCCONSTRUCTIONS 2f80507bc0 docs(k1): record R4 onboard installation and pending hardware acceptance 2026-09-07 12:44:22 +03:00
DCCONSTRUCTIONS d2dbfc9c85 fix(k1): translate onboard operation identities at plugin boundary 2026-09-07 12:34:48 +03:00
DCCONSTRUCTIONS 3dffe39c89 Record installation of the onboard discovery fix 2026-09-07 12:20:27 +03:00
DCCONSTRUCTIONS 77acb377e2 Fix onboard BLE admission and stage wireless device enrollment 2026-09-07 12:11:48 +03:00
DCCONSTRUCTIONS 2fdf1d3cc4 Record private K1 installer release and Mini installation acceptance 2026-09-07 11:56:32 +03:00
DCCONSTRUCTIONS d8cc5367c4 Package onboard K1 separately with a private portable installer 2026-09-07 11:43:24 +03:00
DCCONSTRUCTIONS 111fe3dcfb docs(k1): record Node 0.7.1 installation and acceptance boundaries 2026-09-07 10:57:56 +03:00
DCCONSTRUCTIONS 63ea2bed67 feat(k1): stabilize LAB bridge and isolate onboard device integration 2026-09-07 10:31:33 +03:00
DCCONSTRUCTIONS 88e47fc39a fix(lab): overlay full route semantic masks 2026-08-29 14:14:38 +03:00
DCCONSTRUCTIONS cd16ea28a6 fix(lab): keep full route result indexable 2026-08-29 13:54:10 +03:00
DCCONSTRUCTIONS 45019f8952 feat(lab): seal full RAVNOVES004TREE semantic review 2026-08-29 13:36:37 +03:00
DCCONSTRUCTIONS 2e44e27967 feat(simulation): auto-build source mesh collision 2026-08-28 13:48:44 +03:00
DCCONSTRUCTIONS 2ccf172319 feat(simulation): add Gaussian UGV runtime pipeline 2026-08-28 11:51:48 +03:00
569 changed files with 63617 additions and 5892 deletions
+34
View File
@@ -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 owners 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
+15 -206
View File
@@ -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,15 +7,11 @@ import {
ApplicationPanel,
ApplicationShell,
Button,
Checker,
ColorField,
ControlRow,
HeaderNavigation,
HeaderProfile,
Icon,
Inspector,
RangeControl,
Select,
StatusBadge,
TextField,
UserProfileMenu,
@@ -29,6 +27,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";
@@ -66,8 +66,6 @@ import {
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
@@ -77,22 +75,6 @@ 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;
@@ -170,6 +152,7 @@ export default function App() {
);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
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);
@@ -629,6 +612,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 +623,7 @@ export default function App() {
saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityActions: computeContourSettings.utilityActions,
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
});
const header = (
@@ -801,6 +786,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" ? (
@@ -824,10 +811,13 @@ export default function App() {
onOpenSpatialScene={() => openView("spatial-scene")}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : (
<WorkspaceRenderer
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -1029,132 +1019,8 @@ 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={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
</Window>
<Window
@@ -1180,64 +1046,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
@@ -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";
@@ -762,7 +762,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 +780,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;
@@ -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, toolbar }: { sessionId: 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} toolbar={toolbar} hideTitle />;
}
@@ -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.selected||!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.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]);
if(!p.selected||!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,53 @@
import { Button, Icon, Inspector, InspectorSelectField, LoadingRegion, RangeControl, SegmentedControl, TextField } from '@nodedc/ui-react';
import { endAtDistance, indexAtDistance, routeLength, canSelectSession, canStartPlanningRoute } from '../../core/missions/planner';
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import type { useRegistrationTest } from '../../core/missions/useRegistrationTest';
export function PlanningProjectSettings({p,t,mode,setMode,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; t:ReturnType<typeof useRegistrationTest>;
mode:'scanner'|'recording';setMode:(mode:'scanner'|'recording')=>void;onStart:()=>void;starting:boolean;
}) {
const disabled=p.busy||starting;
const length=routeLength(p.poses);
return <Inspector variant="panel" defaultOpen={['project','zone','route','query']} 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.poses.length.toLocaleString('ru-RU')} положений · {p.source.path_m.toFixed(2)} м</small>}
</div>},
{id:'route',label:'Участок эталона',icon:<Icon name="plan"/>,content:<div className="inspector-control-stack">
{p.source&&!p.sourceChanged?<>
<RangeControl label="Начало участка" value={p.source.poses[p.start]?.distance_m??0} min={0} max={p.source.poses[p.source.poses.length-2].distance_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>{const n=indexAtDistance(p.source!,distance,0,p.source!.poses.length-2);p.setStart(n);if(n>=p.end)p.setEnd(n+1);}}/>
<RangeControl label="Конец участка" value={p.source.poses[p.end]?.distance_m??0} min={p.source.poses[p.start+1]?.distance_m??0} max={p.source.path_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>p.setEnd(indexAtDistance(p.source!,distance,p.start+1,p.source!.poses.length-1,true))}/>
<Button disabled={disabled} onClick={()=>p.setEnd(endAtDistance(p.source!,p.start,30))}>30 м от начала участка</Button>
<Button disabled={disabled} onClick={()=>{p.setStart(0);p.setEnd(p.source!.poses.length-1);}}>Вся траектория</Button>
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
<small>{length.toFixed(2)} м · {p.poses.length.toLocaleString('ru-RU')} положений</small>
</>:<p>Выберите сохранённую запись эталона.</p>}
</div>},
{id:'query',label:'Повторный проход',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<SegmentedControl label="Источник повторного прохода" value={mode} onChange={setMode} items={[{value:'scanner',label:'Новый проход',disabled},{value:'recording',label:'Из записи',disabled}]}/>
{mode==='scanner'?<p>После запуска откроется подключение сканера. Новый проход записывается отдельным проектом.</p>:<>
<InspectorSelectField label="Повторная запись" value={t.sessionId} disabled={disabled} searchable onChange={t.setSessionId}
options={[{value:'',label:'Выберите повторный проход'},...p.sessions.filter(canSelectSession).map(s=>({value:s.id,label:s.label,description:s.id===p.sessionId?'Та же запись · внутренняя проверка':'Сохранённые облако и траектория'}))]}/>
<LoadingRegion loading={t.loading} label="Подготовка повторного прохода">
{t.source&&<div className="inspector-control-stack">
<RangeControl label="Начало повторного участка" min={0} max={t.source.poses[t.source.poses.length-2].distance_m} step="any" value={t.source.poses[t.start].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`}
onChange={n=>{const start=indexAtDistance(t.source!,n,0,t.source!.poses.length-2);t.setStart(start);t.setEnd(endAtDistance(t.source!,start,20));}}/>
<RangeControl label="Конец повторного участка" min={t.source.poses[t.start+1].distance_m} max={t.source.path_m} step="any" value={t.source.poses[t.end].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`} onChange={n=>t.setEnd(indexAtDistance(t.source!,n,t.start+1))}/>
</div>}
</LoadingRegion>
</>}
<small>{mode==='scanner'
?'Длина выбранного участка задаёт предел прохода. Ограничения по времени нет; запись сканера не останавливается автоматически. Неподвижная калибровка просматривает весь выбранный маршрут. После калибровки оставайтесь на месте до статуса «Сопровождение»; при отсутствии или неоднозначности совпадения сцена прямо сообщит причину и не начнёт сопровождение.'
:'Участки от 3 до 40 м. Начальная привязка — выбранное место старта и одинаковое направление.'}</small>
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!canStartPlanningRoute(length,mode)||(mode==='recording'&&(!t.source||t.loading||!canStartPlanningRoute(t.length,'recording')))} onClick={onStart}><Icon name="play"/>{mode==='scanner'?'Начать новый проход':'Запустить совмещение'}</Button>
{(p.error||t.error)&&<p role="alert">{p.error||t.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,80 @@
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 } from "../../core/observation/sessionOverviewSpatial";
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?: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 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);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(setMetadata).catch(() => { if (!disposed) 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();
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).then(result => {
if (abort.signal.aborted || !controller.current) return;
controller.current.channel.send_rrd(result.bytes);
if (result.eye) controller.current.viewer.configure_camera_journal(result.eye, 0);
appliedMode.current = mode;
setVisiblePoints(result.visiblePoints); setViewError(null);
}).catch(() => { if (!abort.signal.aborted) setViewError("Не удалось обновить вид облака."); });
}, 180);
return () => { abort.abort(); clearTimeout(timer); };
}, [state, metadata, sourceUrl, ceiling, mode, retry]);
const low = metadata?.height_min_m;
const high = metadata?.height_max_m;
return <>
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
<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"} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" ? "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>
{viewError ? <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>
: <span className="session-overview__note">{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек</span>}
</>;
}
@@ -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,71 @@
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<void>; selected: boolean; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>; resume:()=>void;
}|null>(null);
/** Server owns the run; navigation and reload retain its frozen reference. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [dismissed,setDismissed]=useState<string|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'}));setDismissed(null);}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
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);setDismissed(null);sessionStorage.removeItem('planning-dismissed');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]);
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{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);}
@@ -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,91 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { canSelectSession, endAtDistance, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
export function useMissionPlanner() {
const initializeRoute = useRef(true);
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 [start, setStart] = useState(0);
const [end, setEnd] = useState(1);
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 [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?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)}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) { const next = validatePlanningSource(data, sessionId); setSource(next); if (initializeRoute.current) { setStart(0); setEnd(endAtDistance(next, 0, 30)); initializeRoute.current = false; } } })
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
return () => abort.abort();
}, [sessionId, sourceVersion]);
const currentSource = source?.session_id === sessionId ? source : null;
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) => { initializeRoute.current = true; setSessionId(id); setStart(0); setEnd(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}`);
initializeRoute.current = false; setSaved(next); setName(next.name); setSessionId(next.zone.session_id);
setStart(next.route.start_index); setEnd(next.route.end_index); 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, start_index: start, end_index: end, 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?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, start, setStart, end, setEnd,
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,39 @@
import { useEffect, useState } from "react";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource } 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);
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 };
}
@@ -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,26 @@
export type OverviewViewMode = "3d" | "top";
export interface OverviewSpatialMetadata { height_min_m: number | null; height_max_m: number | null; sample_points: number; }
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) {
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 }) });
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,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" };
}
+4 -3
View File
@@ -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>,
+14 -15
View File
@@ -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
View File
@@ -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";
-1
View File
@@ -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
View File
@@ -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; } }
-113
View File
@@ -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
View File
@@ -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,3 +1,4 @@
import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
@@ -40,7 +41,7 @@ function ModelCard({
);
}
export function DeviceWorkspace({
function DeviceConnectionBody({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
@@ -128,3 +129,7 @@ export function DeviceWorkspace({
</div>
);
}
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
}
@@ -1,38 +1,26 @@
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
import { usePlanningTest } from "../core/missions/PlanningTestContext";
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 +78,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 +382,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(
@@ -1153,9 +416,10 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
const planning = usePlanningTest();
switch (props.definition.kind) {
case "spatial":
return <SpatialWorkspace {...props} />;
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
case "recordings":
return <RecordingsWorkspace {...props} />;
case "cameras":
@@ -1167,7 +431,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":
@@ -36,6 +36,7 @@ export interface LaboratoryViewAction {
export interface WorkspaceRendererProps {
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,107 @@
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 { useRegistrationTest } from '../../core/missions/useRegistrationTest';
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)=>void;headerToolsHost?:HTMLElement|null}) {
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
const t=useRegistrationTest(p.saved);
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
const [mode,setMode]=useState<'scanner'|'recording'>('scanner');
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();t.setSessionId('');setMode('scanner');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;
if(mode==='scanner') {
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device');}
} else {
const result=await t.run(draft);
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
}
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} 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} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{await live.select(project.id);live.resume();openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene');}}>Открыть текущий проход</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} /> : <><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,642 @@
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({
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"),
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}
</>}
navigationReady={visualProfile ? false : presentedViewerStatus==='ready'} 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}
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');
});
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {execFileSync} from 'node:child_process';
import {createServer} from 'vite';
let server,previewFrames,assertRrd,previewFreshness,previewCamera;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({previewCamera}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewCamera.ts'));
({previewFreshness}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFreshness.ts'));
({previewFrames,assertRrd}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts'));
});
after(async()=>{await server?.close();});
const array=bytes=>Uint8Array.from(bytes).buffer;
const header=size=>{const value=new Uint8Array(8);value.set([77,67,70,49]);new DataView(value.buffer).setUint32(4,size);return value.buffer;};
test('native RRD crosses many RTC fragments and reaches decoder once, byte-for-byte',()=>{
const payload=execFileSync('../../.venv/bin/python',['-c',`import rerun as rr, numpy as np, sys
r=rr.RecordingStream('synthetic-r10-js'); b=r.binary_stream()
r.log('points',rr.Points3D(np.random.default_rng(42).random((5000,3))))
sys.stdout.buffer.write(b.read())`]);
assert.ok(payload.length>16384);
const calls=[];
const receiver=previewFrames(value=>{assertRrd(value);calls.push(value);});
for(let record=0;record<2;record++){
receiver.push(header(payload.length));
for(let offset=0;offset<payload.length;offset+=16384){
receiver.push(array(payload.subarray(offset,offset+16384)));
if(offset+16384<payload.length)assert.equal(calls.length,record);
}
assert.deepEqual(Buffer.from(calls[record]),payload);
}
assert.equal(calls.length,2);
receiver.close();
});
test('partial, oversized, unframed and invalid records never enter the decoder',()=>{
let calls=0;
const receiver=previewFrames(value=>{assertRrd(value);calls++;});
assert.throws(()=>receiver.push(array([1,2,3])));
assert.throws(()=>receiver.push(header(8*1024*1024+1)));
assert.throws(()=>receiver.push(header(0)));
receiver.push(header(20));receiver.push(array([1,2]));receiver.close();
assert.equal(calls,0);
receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(13))));receiver.close();
receiver.push(header(12));assert.throws(()=>receiver.push(array(new Uint8Array(12))));
assert.equal(calls,0);
});
test('source age expires even when metadata repeats, old frames never look live',()=>{
let now=10000;const gate=previewFreshness(()=>now);
assert.equal(gate.fresh,false);
gate.receive({type:'lidar-state',sequence:1,age_ms:250,points:3});
assert.equal(gate.fresh,true);assert.equal(gate.points,3);
now+=1800;gate.receive({type:'lidar-state',sequence:1,age_ms:0,points:3});
assert.equal(gate.fresh,false);
gate.receive({type:'lidar-state',sequence:2,age_ms:9000,points:3});
assert.equal(gate.fresh,false);
gate.receive({type:'lidar-state',sequence:3,age_ms:0,points:4});
assert.equal(gate.fresh,true);assert.equal(gate.points,4);
assert.throws(()=>gate.receive({type:'lidar-state',sequence:4,age_ms:-1,points:4}));
});
test('onboard camera admits immediate sourceopen and ignores callbacks after close',()=>{
const saved={media:globalThis.MediaSource,create:URL.createObjectURL,revoke:URL.revokeObjectURL};
let source,buffer,appended=0,ready=0,failed=0;
class Buffer extends EventTarget {updating=false;buffered={length:0};appendBuffer(){appended++;}}
class Media extends EventTarget {
readyState='open';static isTypeSupported(){return true;}
constructor(){super();source=this;}
addSourceBuffer(){buffer=new Buffer();return buffer;}
}
class Video extends EventTarget {currentTime=0;set src(_){source.dispatchEvent(new Event('sourceopen'));}pause(){}load(){}removeAttribute(){}play(){return Promise.resolve();}}
globalThis.MediaSource=Media;URL.createObjectURL=()=> 'blob:synthetic';URL.revokeObjectURL=()=>{};
try{
const video=new Video(),decoder=previewCamera(video,()=>ready++,()=>failed++);
decoder.open('video/mp4');decoder.push(new Uint8Array([1,2,3]));
assert.equal(appended,1);
video.dispatchEvent(new Event('loadeddata'));assert.equal(ready,1);
decoder.close();video.dispatchEvent(new Event('loadeddata'));buffer.dispatchEvent(new Event('error'));
assert.equal(ready,1);assert.equal(failed,0);
}finally{globalThis.MediaSource=saved.media;URL.createObjectURL=saved.create;URL.revokeObjectURL=saved.revoke;}
});
test('a recovered media peer resumes a recording without reinserting old RRD batches',async()=>{
const {previewRecording}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/previewFrames.ts');
const sent=[];const cursor=previewRecording(value=>sent.push(value));
const payload=new Uint8Array(12);payload.set([82,82,70,50]);
assert.equal(cursor.accept(1,payload),true);
// The ACK was lost, so the new peer resends the exact complete batch.
assert.equal(cursor.accept(1,payload),false);
assert.equal(cursor.after,1);
assert.throws(()=>cursor.accept(3,payload),/cursor gap/);
assert.equal(cursor.accept(2,payload),true);
assert.equal(sent.length,2);
});
@@ -16,6 +16,7 @@ let K1ConnectionPipelines;
let physicalRecoveryConnectionDetail;
let shouldRenderK1OperationalPanels;
let K1ProvisioningPipeline;
let K1WifiPasswordField;
let RuntimeActionFenceTestContext;
let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS;
let emptySearchPresentation;
@@ -102,6 +103,9 @@ before(async () => {
logLevel: "silent",
server: { middlewareMode: true },
});
({K1WifiPasswordField} = await server.ssrLoadModule(
"@xgrids-k1/frontend/components/K1WifiPasswordField.tsx",
));
({ normalizeXgridsK1MissionState } = await server.ssrLoadModule(
"@xgrids-k1/frontend/runtimeContext.tsx",
));
@@ -250,7 +254,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
"const chooseAnother",
);
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
assert.doesNotMatch(search, /\b(?:connect|verifyConnection|submitConnect)\s*\(/);
@@ -277,7 +281,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
assert.match(resultRows, /onSelect=\{\(\) => selectCandidate\(device\)\}/);
assert.doesNotMatch(resultRows, /Переподключиться|reopen|verifyConnection/);
assert.match(source, /onChange=\{\(event\) => setSsid\(event\.target\.value\)\}/);
assert.match(source, /onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/);
assert.match(source, /<K1WifiPasswordField[\s\S]*?onChange=\{setPassword\}/);
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
assert.doesNotMatch(
@@ -290,12 +294,16 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
source,
/(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i,
);
const passwordSource = readFileSync(new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.tsx", import.meta.url,
), "utf8");
for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
assert.match(source, new RegExp(`<${sharedControl}\\b`), sharedControl);
assert.match(source + passwordSource, new RegExp(`<${sharedControl}\\b`), sharedControl);
}
assert.doesNotMatch(passwordSource, /<(?:button|input)\b/);
assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
assert.match(source, /устарел|stale/i);
assert.match(source, /неизвест|outcome-unknown|safe_to_retry/i);
assert.match(source, /network_outcome_unknown|safe_to_retry/i);
});
test("exact network-applied REST proof completes frontend Apply before control is ready", () => {
@@ -377,7 +385,7 @@ test("exact network-applied REST proof completes frontend Apply before control i
);
assert.match(
connectFlow,
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
);
assert.doesNotMatch(
connectFlow,
@@ -1181,7 +1189,7 @@ function elementByProp(node, propName, expectedValue) {
return elementByProp(node.props.children, propName, expectedValue);
}
function createStatefulProvisioningHarness(initialProps) {
function createStatefulProvisioningHarness(initialProps, Component = K1ProvisioningPipeline) {
const hookSlots = [];
let currentProps = initialProps;
let capturedTree = null;
@@ -1296,7 +1304,7 @@ function createStatefulProvisioningHarness(initialProps) {
};
try {
capturedTree = K1ProvisioningPipeline(currentProps);
capturedTree = Component(currentProps);
} finally {
dispatcher.useState = originals.useState;
dispatcher.useRef = originals.useRef;
@@ -1359,7 +1367,7 @@ function actionByLabel(node, label) {
return actionByLabel(node.props.children, label);
}
function renderProvisioningWithAttempt(props, presentation) {
function renderProvisioningWithAttempt(props, presentation, afterSearch = false) {
function AttemptHarness() {
const internals =
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
@@ -1367,6 +1375,12 @@ function renderProvisioningWithAttempt(props, presentation) {
assert.equal(typeof dispatcher?.useState, "function");
const originalUseState = dispatcher.useState;
dispatcher.useState = (initialState) => {
if (afterSearch && initialState === emptySearchPresentation) {
const state = props.controller.state;
return [{ sequence: 1, snapshotRuntimeId: state.snapshot_runtime_id,
connectionMode: props.desiredMode, desiredModeRevision: state.desired_connection_mode_revision,
active: false, completedDiscoveryGeneration: state.ble_discovery_generation }, () => undefined];
}
if (initialState === emptyProvisioningAttemptPresentation) {
return [presentation, () => undefined];
}
@@ -2487,10 +2501,11 @@ test("toolbar reset in one runtime rejects late Scan A and admits exactly one Sc
}
});
test("local connection navigation and selector copy use process nouns", () => {
test("operator-local connection belongs to LAB and retains all test modes", () => {
const localConnection = workspaces.find((workspace) => workspace.id === "local-device");
assert.equal(localConnection?.label, "Подключение");
assert.equal(localConnection?.title, "Подключение");
assert.equal(localConnection?.root, "polygon");
assert.equal(localConnection?.label, "Тестовые устройства");
assert.equal(localConnection?.title, "Тестовые устройства");
assert.equal(model().displayName, "XGRIDS LixelKity K1");
assert.deepEqual(
connectionModeOptions.map(({ label }) => label),
@@ -2841,8 +2856,9 @@ test("compact connection error can expose only its contextual recovery path", ()
}));
assert.match(markup, /Подключение не завершено/);
assert.match(markup, />Подключить новый K1<\/button>/);
assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 1);
assert.doesNotMatch(markup, /Проверить состояние/);
assert.equal(buttonMarkupWithText(markup, "Закрыть").length, 0);
});
test("connection attempt network phase copy distinguishes absent, applied and unknown outcomes", () => {
@@ -4341,16 +4357,16 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
assert.match(markup, /Повторить поиск Bluetooth/);
assert.doesNotMatch(markup, /old-stop-operation/);
const priorAction = deviceRowActionButton(markup, "ble-k1-001");
assert.match(priorAction, />Выбрать<\/button>/);
assert.equal(priorAction.replace(/<[^>]+>/g, ""), "Выбрать");
assert.doesNotMatch(priorAction, /\bdisabled(?:=|\s|>)/);
const replacementAction = deviceRowActionButton(markup, "ble-k1-new");
assert.match(replacementAction, />Выбрать<\/button>/);
assert.equal(replacementAction.replace(/<[^>]+>/g, ""), "Выбрать");
assert.doesNotMatch(replacementAction, /\bdisabled(?:=|\s|>)/);
assert.equal(
buttonMarkupWithText(markup, "Подключить новый K1").length,
0,
);
assert.doesNotMatch(markup, /<button[^>]*disabled[^>]*>Недоступно<\/button>/);
assert.equal(buttonMarkupWithText(markup, "Недоступно").filter(button => /<button[^>]*disabled/.test(button)).length, 0);
assert.doesNotMatch(
markup,
/Попытка настройки завершена|Проверить подключение без изменения сети|Управление не подтверждено/,
@@ -4373,7 +4389,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
/Подходящих K1 не найдено\. Повторите поиск\./,
);
const onlyPriorAction = deviceRowActionButton(onlyPriorMarkup, "ble-k1-001");
assert.match(onlyPriorAction, />Выбрать<\/button>/);
assert.equal(onlyPriorAction.replace(/<[^>]+>/g, ""), "Выбрать");
assert.doesNotMatch(onlyPriorAction, /\bdisabled(?:=|\s|>)/);
assert.match(onlyPriorMarkup, /Результатов: 1/);
assert.doesNotMatch(onlyPriorMarkup, />Недоступно|Переподключиться/);
@@ -4400,7 +4416,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
historicalAuditMarkup,
"ble-k1-001",
);
assert.match(historicallyRetiredAction, />Выбрать<\/button>/);
assert.equal(historicallyRetiredAction.replace(/<[^>]+>/g, ""), "Выбрать");
assert.doesNotMatch(
historicallyRetiredAction,
/\bdisabled(?:=|\s|>)/,
@@ -4490,7 +4506,7 @@ test("an exact prior CoreBluetooth UUID remains a normal fresh selection across
markup,
"f89438fa-55ed-85ad-eed7-734ac84746d8",
);
assert.match(priorDeviceAction, />Выбрать<\/button>/);
assert.equal(priorDeviceAction.replace(/<[^>]+>/g, ""), "Выбрать");
assert.doesNotMatch(priorDeviceAction, /\bdisabled(?:=|\s|>)/);
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
assertCanonicalConnectionCopy(markup);
@@ -4662,23 +4678,11 @@ test("post-reset Scan selects the exact prior UUID locally and opens network fie
"local selection must not manufacture a backend Apply attempt",
);
const passwordField = elementByProp(tree, "label", "Пароль WiFi");
const passwordField = elementByProp(tree, "placeholder", "Введите пароль");
assert.ok(passwordField);
assert.equal(passwordField.props.type, "password");
passwordField.props.onChange({ target: { value: "test-only-password" } });
passwordField.props.onChange("test-only-password");
tree = harness.render(props());
harness.flushEffects();
const showPassword = elementByProp(tree, "label", "Показать пароль");
assert.ok(showPassword);
assert.equal(showPassword.props.disabled, false);
showPassword.props.onClick();
tree = harness.render(props());
harness.flushEffects();
assert.equal(
elementByProp(tree, "label", "Пароль WiFi").props.type,
"text",
);
assert.ok(elementByProp(tree, "label", "Скрыть пароль"));
const chooseAnother = actionByLabel(tree, "Выбрать другое");
assert.ok(chooseAnother);
@@ -6109,7 +6113,7 @@ test("an explicit network action keeps one disabled Step-2 form with an in-place
assert.match(markup, /Пароль передан/);
assert.doesNotMatch(markup, /secret/);
assert.equal((markup.match(/<input[^>]*disabled=""/g) ?? []).length, 2);
assert.doesNotMatch(markup, />Применить<\/button>/);
assert.equal(buttonMarkupWithText(markup, "Применить").length, 0);
assertCanonicalConnectionCopy(markup);
});
@@ -6294,8 +6298,8 @@ test("current Bridge bootstrap stays visible over an older unresolved STOP check
assert.match(markup, /<span>02<\/span>/);
assert.match(markup, /nodedc-activity-indicator/);
assert.doesNotMatch(markup, /Прежнее подключение не подтверждено/);
assert.doesNotMatch(markup, />Переподключиться<\/button>/);
assert.doesNotMatch(markup, />Подключить новый K1<\/button>/);
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 0);
assert.equal(isConnectionControlBootstrapSettling({
...state,
@@ -6451,7 +6455,8 @@ test("correlated terminal recovery resets before exposing a separate clean Scan"
buttonMarkupWithText(markup, "Проверить прежнее подключение").length,
0,
);
assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
assert.doesNotMatch(markup, /Проверить состояние/);
assert.equal(buttonMarkupWithText(markup, "Закрыть").length, 0);
assert.doesNotMatch(markup, /Выбрать другое|Пароль Wi‑Fi|Пароль передан|>Применить</);
assert.doesNotMatch(markup, /<span>02<\/span>|private transport exception/);
@@ -7135,7 +7140,7 @@ test("a stale reopen settlement cannot own the current wizard surface", () => {
assert.match(markup, /<span>01<\/span>/);
assert.doesNotMatch(markup, /Подключение…|aria-busy="true"/);
assert.doesNotMatch(markup, /<span>02<\/span>|<span>03<\/span>/);
assert.match(markup, />Найти по Bluetooth<\/button>/);
assert.equal(buttonMarkupWithText(markup, "Найти по Bluetooth").length, 1);
const modeToggle = markup.match(
/<button[^>]*aria-label="Способ подключения"[^>]*>/,
)?.[0];
@@ -7185,7 +7190,7 @@ test("a current-authority Bluetooth scan owns Step 01 and no network step", () =
});
assert.match(markup, /<span>01<\/span>/);
assert.match(markup, /Поиск Bluetooth · 6 с/);
assert.match(markup, /Поиск Bluetooth · до 20 с/);
assert.equal(
(markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
1,
@@ -7255,7 +7260,7 @@ test("stale raw Bluetooth results stay hidden until an explicit search", () => {
assert.match(markup, /<span>01<\/span>/);
assert.doesNotMatch(markup, /Replacement K1/);
assert.match(markup, />Найти по Bluetooth<\/button>/);
assert.equal(buttonMarkupWithText(markup, "Найти по Bluetooth").length, 1);
assert.doesNotMatch(markup, /aria-busy="true"/);
assert.doesNotMatch(markup, /<span>02<\/span>|<span>03<\/span>/);
assert.doesNotMatch(markup, /Завершаем предыдущее действие|Завершаем ранее начатое действие/);
@@ -7326,7 +7331,7 @@ test("Step-2 credentials and Apply share one canonical field stack", () => {
assert.match(
form,
/:\s*\(\s*<div className="field-stack"[\s\S]*?label="Пароль WiFi"[\s\S]*?\{applyAction\}\s*<\/div>/,
/:\s*\(\s*<div className="field-stack"[\s\S]*?<K1WifiPasswordField[\s\S]*?\{applyAction\}\s*<\/div>/,
);
assert.match(form, /disabled=\{provisioningFieldsDisabled\}/);
assert.match(form, /value=\{connectionAttemptOwnsDraft \? "" : password\}/);
@@ -7478,7 +7483,7 @@ test("applied-network recovery spends the old intent and admits only explicit se
search,
/const recoveryScanAttempt = appliedRecoveryEscape[\s\S]*?canScanAppliedRecovery[\s\S]*?unresolvedAppliedAttempt/,
);
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: 6 \}\)/);
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
assert.match(
search,
/if \(scanResult\.succeeded && recoveryAttemptKey\) \{\s*setEscapedAppliedAttemptKey\(recoveryAttemptKey\)/,
@@ -7668,6 +7673,26 @@ test("spatial controls explain the bounded K1 calibration wait before point data
}
});
test("planning status extends only its current authoritative acquiring session", () => {
const state = runtimeState();
const spatialActivity = {sessionId: "data-session-001", label: "Привязка к эталону", detail: "Ожидание на месте.", busy: true};
const render = (current, activity = spatialActivity, controllerPatch = {}) => renderToStaticMarkup(createElement(K1SpatialControlsView, {
controller: {...acquisitionController(current), ...controllerPatch}, spatialActivity: activity,
}));
assert.match(render(state), /Привязка к эталону/);
assert.match(render(state), /Ожидание на месте/);
assert.doesNotMatch(render(state, {...spatialActivity, sessionId: "other-capture"}), /Привязка к эталону/);
for (const acquisitionState of ["awaiting_external_start", "starting", "awaiting_external_stop", "stopping", "finalizing"]) {
const current = structuredClone(state); current.acquisition.state = acquisitionState;
assert.doesNotMatch(render(current), /Привязка к эталону/);
}
const lost = structuredClone(state); lost.connection_supervisor = supervisor({control: true, data: false, dataPlaneState: "stalled"});
assert.doesNotMatch(render(lost), /Привязка к эталону/);
assert.doesNotMatch(render(state, spatialActivity, {physicalStopInFlight: true}), /Привязка к эталону/);
const cleanup = structuredClone(state); cleanup.acquisition.cleanup_pending = true;
assert.doesNotMatch(render(cleanup), /Привязка к эталону/);
});
test("contour health never promotes selection or replay metrics to live authority", () => {
const selectedOnly = {
phase: "starting",
@@ -7708,3 +7733,80 @@ test("contour health never promotes selection or replay metrics to live authorit
});
assert.equal(replay.aiActive, false);
});
test("the recovery panel explains the reviewed K1 network-not-found reply", () => {
const state = terminalConnectionRecoveryState();
state.connection_attempt.public_error_code = "k1-wifi-network-not-found";
const markup = renderToStaticMarkup(createElement(K1OperatorError, {
attempt: state.connection_attempt, onRefresh() {}, onClear() {},
}));
assert.match(markup, /<strong>Не удалось подключить K1 к WiFi<\/strong>/);
assert.match(markup, /указанная сеть не найдена/);
assert.match(markup, /Проверьте название сети и пароль/);
assert.doesNotMatch(markup, /ошибкой Bluetooth|INVALID_PDU/);
});
test("a reviewed Wi-Fi rejection offers explicit network setup for the same K1", () => {
const state = terminalConnectionRecoveryState();
state.connection_attempt.public_error_code = "k1-wifi-network-not-found";
const controller = {
...provisioningController(state), error: "private transport error",
errorCorrelation: {
action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0,
connectionAttemptId: state.connection_attempt.attempt_id,
},
getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state),
};
const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" },
provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id,
attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true);
assert.equal(buttonMarkupWithText(markup, "Указать сеть Wi‑Fi заново").length, 1);
assert.doesNotMatch(markup, /Подключить новый K1|private transport error/);
});
test("a failed Connect after completed Bluetooth search exposes recovery instead of a locked form", () => {
const state = terminalConnectionRecoveryState();
state.ble_discovery_generation = 6;
state.connection_attempt.public_error_code = "BleakGATTProtocolError";
state.connection_attempt.stage = "gatt-write-failed";
const controller = {
...provisioningController(state),
error: "private transport exception must stay hidden",
errorCorrelation: {
action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0,
connectionAttemptId: state.connection_attempt.attempt_id,
},
getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state),
};
const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" },
provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id,
attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true);
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 1);
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 1);
assert.match(markup, /K1 ответил ошибкой Bluetooth/);
assert.doesNotMatch(markup, /Подключение не выполнено|дождитесь, пока система|private transport exception|Пароль передан/);
});
test("shared K1 password field reveals on demand and hides after clearing", () => {
let value = "";
const props = () => ({value, onChange:next=>{value=next;}, disabled:false});
const harness = createStatefulProvisioningHarness(props(), K1WifiPasswordField);
try {
let tree = harness.render(props());
assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true);
elementByProp(tree, "label", "Пароль WiFi").props.onChange({target:{value:"test-only-value"}});
tree = harness.render(props()); harness.flushEffects();
elementByProp(tree, "label", "Показать пароль").props.onClick();
tree = harness.render(props());
assert.equal(elementByProp(tree, "label", "Пароль WiFi").props.type, "text");
assert.equal(elementByProp(tree, "label", "Скрыть пароль").props['aria-pressed'], true);
value = "";
tree = harness.render(props()); harness.flushEffects();
tree = harness.render(props());
assert.equal(elementByProp(tree, "label", "Пароль WiFi").props.type, "password");
assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true);
assert.equal(elementByProp(tree, "label", "Пароль WiFi").props.autoComplete, "off");
} finally { harness.dispose(); }
});
@@ -804,7 +804,7 @@ test("E40 reports historical visible evaluation with bounded camera-LiDAR case r
});
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
const workspacesSource = await readFile(workspacesUrl, "utf8");
const workspacesSource = await readFile(new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8");
assert.match(workspacesSource, /if \(!pointCloudFocused\) return;/);
assert.match(workspacesSource, /event\.key !== "Escape"/);
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { createServer } from 'vite';
let server, api;
before(async () => { server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); api = await server.ssrLoadModule('/src/core/missions/planner.ts'); });
after(async () => { await server?.close(); });
const source = { schema_version:'missioncore.planning-source/v1', session_id:'A', generation:'a'.repeat(64), units:'m', poses:Array.from({length:5}, (_, i) => ({index:i, position:[i*3,i*4,0], distance_m:i*5})) };
test('route reverses source order without changing geometry or source', () => {
const result = api.selectedPoses(source, 1, 3, 'reverse');
assert.deepEqual(result.map(p => p.index), [3,2,1]); assert.equal(api.routeLength(result), 10); assert.equal(source.poses[0].index,0);
});
test('bounded section uses travelled distance, including out-and-back', () => {
assert.equal(api.endAtDistance(source, 1, 7),3); assert.equal(api.endAtDistance(source, 1, 30),4); assert.deepEqual(api.selectedPoses(source,4,2,'forward'),[]);
});
test('catalog excludes derived LAB parent and nonspatial sessions from zone binding', () => {
const item={replayable:true,modalities:['point-cloud','trajectory']}; assert.equal(api.canSelectSession(item),true);
assert.equal(api.canSelectSession({...item,lab:{}}),false); assert.equal(api.canSelectSession({...item,modalities:['video']}),false);
});
test('source contract rejects a different session and nonfinite coordinates', () => {
assert.equal(api.validatePlanningSource(source,'A'),source); assert.throws(() => api.validatePlanningSource(source,'B'));
assert.throws(() => api.validatePlanningSource({...source,poses:[{index:0,position:[0,0,NaN],distance_m:0},source.poses[1]]},'A'));
});
test('meter input selects the correct ordered pose and respects the selected branch', () => {
assert.equal(api.indexAtDistance(source, 10), 2);
assert.equal(api.indexAtDistance(source, 10, 3), 3);
assert.equal(api.indexAtDistance(source, 100), 4);
});
test('live distance has no upper cap and endpoint selection does not overshoot typed metres', () => {
for (const n of [3, 30, 50, 100, 200, 300, 10000]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), true);
for (const n of [2.9, NaN, Infinity]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), false);
assert.equal(api.canStartPlanningRoute(50, 'recording'), false);
assert.equal(api.indexAtDistance(source, 9.9, 1, 4, true), 1);
assert.equal(api.indexAtDistance(source, 10, 1, 4, true), 2);
assert.equal(api.indexAtDistance(source, 1, 1, 4, true), 1);
});
test('planning match never promotes stopped or stale evidence', async()=>{
const {planningMatchCurrent}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',tracking_state:'tracking',stale:false,frame_age_s:0,result_age_s:2,result:{status:'candidate'}};
assert.equal(planningMatchCurrent(t),true);
for(const patch of [{state:'completed'},{state:'waiting'},{tracking_state:'acquiring'},{tracking_state:'lost'},{tracking_state:undefined},{stale:true},{frame_age_s:8},{result_age_s:8},{result_age_s:null},{result:{status:'rejected'}}])assert.equal(planningMatchCurrent({...t,...patch}),false);
assert.equal(planningMatchCurrent(t,true),false);
});
test('planning failure is visible even when its last sample is stale', async()=>{
const {planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'error',message:'Разрыв координат',stale:true,frame_age_s:null,result_age_s:null,result:null};
assert.deepEqual(planningStatus(t),{label:'Совмещение остановлено',tone:'danger',message:'Разрыв координат',pulse:true});
assert.equal(planningStatus({...t,state:'waiting'}).label,'Ожидание данных');
});
test('planning activity continues scanner preparation without presenting a prior as tracking', async()=>{
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',query_session_id:'B',tracking_state:'acquiring',stale:false,frame_age_s:0,result_age_s:null,result:null,message:'Ожидание на месте.'};
assert.equal(planningActivity({...t,planning_phase:'waiting-cloud'}),undefined);
for(const [planning_phase,label] of [['collecting','Накопление данных'],['searching','Поиск положения на маршруте'],['refreshing','Подтверждение привязки'],['validating','Подтверждение привязки']]){
assert.deepEqual(planningActivity({...t,planning_phase}),{sessionId:'B',label,detail:'',busy:true});
assert.equal(planningStatus({...t,planning_phase}).tone,'neutral');
}
const live={...t,planning_phase:'tracking',tracking_state:'tracking',result_age_s:0,result:{status:'candidate'},message:'Можно начинать проверочный проход.'};
assert.equal(planningActivity(live).busy,false);
assert.equal(planningStatus(live).tone,'success');
const stale=planningStatus({...live,frame_age_s:9});
assert.equal(stale.label,'Привязка потеряна');assert.doesNotMatch(stale.message,/Можно начинать/);
assert.equal(planningActivity({...t,planning_phase:'searching'},'Нет связи').busy,false);
assert.equal(planningActivity({...live,query_session_id:null}),undefined);
assert.equal(planningActivity({...live,state:'completed'}),undefined);
const recovery=planningStatus({...t,planning_phase:'recovering',tracking_established:true});
assert.equal(recovery.pulse,true);
assert.equal(recovery.tone,'danger');
assert.match(recovery.message,/запись продолжается/);
});
test('project archive restores exact run identity and never promotes a failed live probe', async()=>{
const {defaultPlanningProject,planningProjectStatus}=await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
const items=[{key:'live:failed',kind:'live',state:'error',result_status:null},{key:'recorded:second',kind:'recorded',state:'ready',result_status:'candidate'},{key:'recorded:first',kind:'recorded',state:'ready',result_status:'candidate'}];
assert.equal(defaultPlanningProject(items,null),'recorded:second');
assert.equal(defaultPlanningProject(items,'recorded:first'),'recorded:first');
assert.equal(defaultPlanningProject(items,'missing'),'recorded:second');
assert.equal(planningProjectStatus(items[0]),'Без результата совмещения');
assert.equal(defaultPlanningProject([],null),'');
});
test('failed initial binding never claims previously established tracking was lost', async()=>{
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',query_session_id:'B',planning_phase:'lost',tracking_state:'lost',tracking_established:false,
stale:true,frame_age_s:null,result_age_s:null,result:null,message:'Начальный поиск не завершён.'};
const failed=planningStatus(t);
assert.equal(failed.label,'Маршрут не синхронизирован');
assert.equal(failed.message,'Начальный поиск не завершён.');
assert.deepEqual(planningActivity(t),{sessionId:'B',label:failed.label,detail:failed.message,busy:false});
assert.equal(planningStatus({...t,tracking_established:true}).label,'Привязка потеряна');
assert.equal(planningStatus({...t,state:'completed'}).label,'Исследование завершено');
});
test('freshness of displayed alignment fences green independently of the fit age',async()=>{
const {planningMatchCurrent,planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',tracking_state:'tracking',planning_phase:'tracking',tracking_established:true,
stale:false,frame_age_s:.2,result_age_s:4,result:{status:'candidate'},message:'Привязка подтверждена.',presentation_state:'live'};
assert.equal(planningMatchCurrent(t),true);
assert.equal(planningMatchCurrent({...t,presentation_state:'historical'}),false);
assert.equal(planningMatchCurrent({...t,frame_age_s:2.1}),false);
assert.match(planningStatus({...t,presentation_state:'historical'}).message,/последней принятой/);
assert.match(planningStatus({...t,state:'completed',presentation_state:'historical'}).message,/не текущее положение/);
});
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { before, after, test } from "node:test";
import { createServer } from "vite";
let server;
let observeProvisioningRequest;
let networkProvisionFailureMessage;
let ApiError;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } });
({ observeProvisioningRequest, networkProvisionFailureMessage } = await server.ssrLoadModule("@xgrids-k1/frontend/networkProvisioning.ts"));
({ ApiError } = await server.ssrLoadModule("@xgrids-k1/frontend/api.ts"));
});
after(async () => { await server?.close(); });
test("reviewed station errors explain Wi-Fi recovery without claiming a Bluetooth failure", () => {
for (const [code, pattern] of [
["k1-wifi-network-not-found", /указанная сеть не найдена/],
["k1-wifi-credentials-required", /устройство запросило учётные данные сети/],
]) {
const message = networkProvisionFailureMessage({ status: "failed", error: { code } });
assert.match(message, pattern);
assert.match(message, /Проверьте название сети и пароль/);
assert.doesNotMatch(message, /INVALID_PDU|ошибкой Bluetooth|Переподключиться/);
}
assert.match(networkProvisionFailureMessage({ status: "failed", error: {
code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU",
} }), /INVALID_PDU/);
assert.match(networkProvisionFailureMessage({ status: "failed", error: {
code: "network-not-found",
} }), /macOS/);
});
const request = { device_id: "synthetic-k1", connection_mode: "bridge", idempotency_key: "explicit-one" };
function snapshot(status, revision = 2) {
return {
snapshot_runtime_id: "runtime-one",
snapshot_runtime_started_at_utc: "2026-09-06T00:00:00Z",
snapshot_revision: revision,
operations: status ? [{
operation_id: "op-one", action: "network.provision", idempotency_key: request.idempotency_key,
status, error: status === "failed" ? {
code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU",
device_write_attempted: true, device_write_confirmed: false,
safe_to_retry: false, side_effect_status: "unknown",
} : null,
}] : [],
};
}
function context(overrides = {}) {
let current = snapshot(null, 1);
const calls = [];
return {
calls,
initialState: current,
send: async (input) => { calls.push(["send", input]); throw new ApiError("transport", 502); },
readState: async () => { calls.push(["read"]); return snapshot("failed"); },
acceptState: (incoming) => { if (incoming.snapshot_revision >= current.snapshot_revision) current = incoming; },
currentState: () => current,
assertCurrent() {},
...overrides,
};
}
test("HTTP 502 retains the exact failed snapshot and ATT facts after one submission", async () => {
const ctx = context();
const result = await observeProvisioningRequest(request, ctx);
assert.deepEqual(ctx.calls.map(([kind]) => kind), ["send", "read"]);
assert.equal(ctx.calls[0][1], request);
assert.equal(result.operation.operation_id, "op-one");
assert.equal(result.operation.error.ble_att_error_name, "INVALID_PDU");
assert.equal(result.state.operations[0], result.operation);
assert.equal(result.transportError.status, 502);
});
test("an existing failed or successful intent is never resubmitted", async () => {
for (const status of ["failed", "succeeded"]) {
const ctx = context({ initialState: snapshot(status) });
const result = await observeProvisioningRequest(request, ctx);
assert.equal(result.operation.status, status);
assert.equal(ctx.calls.length, 0);
}
});
test("lost HTTP response can observe journal success without another command", async () => {
const ctx = context({ readState: async () => snapshot("succeeded") });
const result = await observeProvisioningRequest(request, ctx);
assert.equal(result.operation.status, "succeeded");
assert.equal(ctx.calls.filter(([kind]) => kind === "send").length, 1);
});
test("an admitted pending intent is followed only by bounded local state reads", async () => {
let now = 0;
const ctx = context({
initialState: snapshot("running"),
settlementOptions: { now: () => now, wait: async (ms) => { now += ms; } },
});
const result = await observeProvisioningRequest(request, ctx);
assert.equal(result.operation.status, "failed");
assert.deepEqual(ctx.calls.map(([kind]) => kind), ["read"]);
});
test("newer WebSocket failure outranks a stale running REST result", async () => {
const newer = snapshot("failed", 7);
const ctx = context({
send: async () => snapshot("running", 5),
currentState: () => newer,
});
const result = await observeProvisioningRequest(request, ctx);
assert.equal(result.state, newer);
assert.equal(result.operation.status, "failed");
assert.equal(ctx.calls.length, 0);
});
test("superseding an intent after dispatch stops continuation without replay", async () => {
let active = true;
const ctx = context({
send: async () => { active = false; throw new ApiError("lost", 502); },
assertCurrent: () => { if (!active) throw new Error("superseded"); },
});
await assert.rejects(observeProvisioningRequest(request, ctx), /superseded/);
assert.equal(ctx.calls.length, 0);
});
test("an unrelated journal row cannot confirm the current command", async () => {
const unrelated = snapshot("succeeded");
unrelated.operations[0].idempotency_key = "another-intent";
const ctx = context({ readState: async () => unrelated });
const result = await observeProvisioningRequest(request, ctx);
assert.equal(result.operation, null);
assert.equal(result.transportError.status, 502);
assert.equal(ctx.calls.length, 1);
});
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import {after,before,test} from 'node:test';
import {createServer} from 'vite';
let server,activation,x4;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
activation=await server.ssrLoadModule('../../packages/sensor-ui/src/observationStart.ts');
x4=await server.ssrLoadModule('../../plugins/insta360-x4/frontend/src/observationStart.ts');
});
after(async()=>{await server?.close();});
const device=(id='camera-1')=>({id,online:true,prepared:true,layers:['color'],snapshot:{acquisition:'idle',context:{session_id:'session-1',execution:{node_id:'board-1'}}},camera_status:{connected:true,preview:0,recording:0}});
function transport(items,submit=()=>({state:'complete'})){
const commands=[];return {commands,inventory:async()=>({fresh:true,items}),submit:async c=>{commands.push(c);return submit(c);},operation:async()=>({state:'complete'})};
}
test('opening two views shares one start; another camera keeps its own lifecycle',async()=>{
const a=device(),b=device('camera-2'),t=transport([a,b]);
await Promise.all([activation.startCameraObservation(a,t),activation.startCameraObservation(a,t),activation.startCameraObservation(b,t)]);
assert.equal(t.commands.length,2);
assert.deepEqual(t.commands.map(c=>c.session.device_id).sort(),[a.id,b.id]);
for(const c of t.commands){assert.equal(c.action_id,'start');assert.deepEqual(c.parameters,{record:false});}
});
test('opening observer cannot replace active capture, recording or playback',async()=>{
for(const value of [{snapshot:{acquisition:'streaming'}},{snapshot:{acquisition:'starting'}},{snapshot:{acquisition:'stopping'}},{playback_id:'recorded-source'}]){
const a=device();Object.assign(a,{...value,snapshot:{...a.snapshot,...value.snapshot}});const t=transport([a]);
await activation.startCameraObservation(a,t);assert.equal(t.commands.length,0);
}
});
test('stale inventory and replaced sessions never receive starts',async()=>{
const original=device(),replacement=device();replacement.snapshot.context.session_id='new-session';
for(const t of [transport([replacement]),{...transport([original]),inventory:async()=>({fresh:false,items:[original]})}]){
await assert.rejects(activation.startCameraObservation(original,t));assert.equal(t.commands.length,0);
}
});
test('X4 auto start uses preview only and attaches without restarting an active camera',async()=>{
const a=device(),t=transport([a]);await x4.startX4Observation(a,t);assert.deepEqual(t.commands.map(c=>c.action_id),['preview.start']);
a.camera_status.preview=1;t.commands.length=0;await x4.startX4Observation(a,t);assert.equal(t.commands.length,0);
});
test('stalled X4 preview recovers once for both lenses without any recording action',async()=>{
const a=device();a.camera_status.preview=1;const t=transport([a]);
await Promise.all([x4.startX4Observation(a,t,true),x4.startX4Observation(a,t,true)]);
assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop','preview.start']);
});
test('X4 recovery requires confirmed stopped recording and successful preview stop',async()=>{
for(const recording of [1,null,-1]){const a=device();a.camera_status={connected:true,preview:1,recording};const t=transport([a]);await assert.rejects(x4.startX4Observation(a,t,true));assert.equal(t.commands.length,0);}
const a=device();a.camera_status.preview=1;const t=transport([a],()=>({state:'unknown'}));
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
});
test('recovery cannot start a replacement camera session after its stop',async()=>{
const a=device();a.camera_status.preview=1;const b=device();b.snapshot.context.session_id='replacement';
const items=[a],t=transport(items,()=>{items[0]=b;return {state:'complete'};});
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
});
@@ -414,7 +414,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
test("recording preparation statuses share the viewer's left alignment", async () => {
const spatialStyles = await readFile(
new URL("../src/styles/spatial.css", import.meta.url),
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
"utf8",
);
const statusStack = spatialStyles.slice(
@@ -1066,7 +1066,7 @@ test("recording preparation never presents phase heartbeats as fake percentages"
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
const workspaceSource = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
"utf8",
);
@@ -27,7 +27,7 @@ async function read(relativePath) {
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
@@ -138,7 +138,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
assert.match(sharedReplay, /timelineStartSeconds/);
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
assert.match(
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFileSync} from 'node:fs';
test('planning live scene reuses the canonical vertical height range inside the existing stage',()=>{
const source=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
assert.match(source,/RangeControl orientation="vertical" limitSide="left" label="Срез"/);
assert.match(source,/className="session-overview__height"/);
assert.match(source,/ceiling_m:ceilingRef\.current/);
assert.match(source,/formatLimit=\{value=>value\.toFixed\(1\)\.replace\('\.',','\)\}/);
assert.match(source,/heightBounds=null/);
assert.match(source,/const CLIP_CEILING_M=80/);
assert.match(source,/max:CLIP_CEILING_M/);
assert.match(source,/setBounds\(next\);setCeiling\(null\);/);
});
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
let server,createReporter;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({createPlanningPresentationReporter:createReporter}=await server.ssrLoadModule(
'/src/core/missions/planningPresentationTelemetry.ts'));
});
after(async()=>{await server?.close();});
const settle=()=>new Promise(resolve=>setImmediate(resolve));
test('presentation observations batch separately from the scene channel and retain the proxy boundary',async()=>{
let timer,posted;
const reporter=createReporter({url:'/observations',schedule:callback=>{timer=callback;return 1;},cancel:()=>{},
fetcher:async(url,init)=>{posted={url,init};return new Response(null,{status:204});},
});
reporter.record({presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12'},
{rerunAdmissionMs:2,firstAnimationFrameMs:12,secondAnimationFrameMs:28});
assert.ok(timer);timer();await settle();
assert.equal(posted.url,'/observations');
const body=JSON.parse(posted.init.body);
assert.equal(body.schema_version,'missioncore.planning-browser-presentation/v1');
assert.deepEqual(body.samples,[{cloud_revision:3,cloud_sequence:4,
display_epoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',request_ms:40,
rerun_admission_ms:2,first_animation_frame_ms:12,second_animation_frame_ms:28,
frame_timeout:false,source_to_second_animation_frame_upper_bound_ms:270}]);
reporter.dispose();
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { readFile } from 'node:fs/promises';
import { createServer } from 'vite';
let server, api;
before(async () => {
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
api = await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
});
after(async () => { await server?.close(); });
const item = {key: 'live:a', id: 'a', name: 'same name', kind: 'live', state: 'completed', revision: 2};
test('only terminal runs or drafts admit catalog deletion', () => {
for (const state of ['completed', 'cancelled', 'error', 'interrupted']) assert.equal(api.planningProjectDeletable({...item, state}), true);
for (const state of ['running', 'waiting', 'preparing', 'new-unknown']) assert.equal(api.planningProjectDeletable({...item, state}), false);
assert.equal(api.planningProjectDeletable({...item, kind: 'draft', state: 'draft'}), true);
assert.equal(api.planningProjectDeletable({...item, kind: 'recorded', state: 'ready'}), true);
});
test('delete sends exact kind/id/revision and requires a matching receipt', async t => {
const calls = [];
let receipt = {key: item.key, deleted: true};
t.mock.method(globalThis, 'fetch', async (...args) => { calls.push(args); return {ok: true, json: async () => receipt}; });
await api.deletePlanningProject(item);
assert.equal(calls[0][0], '/api/v1/mission-planner/projects/live/a');
assert.equal(calls[0][1].method, 'DELETE');
assert.deepEqual(JSON.parse(calls[0][1].body), {revision: 2});
receipt = {key: 'live:another-same-name', deleted: true};
await assert.rejects(api.deletePlanningProject(item), /не подтверждено/);
const count = calls.length;
await assert.rejects(api.deletePlanningProject({...item, state: 'running'}), /завершите/);
assert.equal(calls.length, count);
});
test('server refusal stays an error, never a successful deletion', async t => {
t.mock.method(globalThis, 'fetch', async () => ({ok: false, json: async () => ({detail: 'Проект изменён.'})}));
await assert.rejects(api.deletePlanningProject(item), /Проект изменён/);
});
test('UI uses canonical row actions and modal, and stale loads cannot resurrect a deleted item', async () => {
const component = await readFile(new URL('../src/components/missions/PlanningProjectSelect.tsx', import.meta.url), 'utf8');
const hook = await readFile(new URL('../src/core/missions/usePlanningProjects.ts', import.meta.url), 'utf8');
assert.match(component, /<Select/); assert.match(component, /<ConfirmationModal/);
assert.match(component, /disabled: !planningProjectDeletable\(item\)/);
assert.match(component, /await onRemove\(target\); setTarget\(null\)/);
assert.match(component, /<ToastStack/);
assert.match(hook, /removedKeys.current.has\(item.key\)/);
assert.match(hook, /removedKeys.current.has\(key\)/);
assert.match(hook, /if \(selectedKey.current === project.key\) select\(''\)/);
});
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
import {readFileSync} from 'node:fs';
let server,start;
before(async()=>{server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});({startPlanningSceneStream:start}=await server.ssrLoadModule('/src/core/missions/planningSceneStream.ts'));});
after(async()=>{await server?.close();});
const settle=()=>new Promise(resolve=>setImmediate(resolve));
function fixture(){
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
const timers=new Map(),requests=[],applied=[],errors=[];
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
fetcher:(url,init)=>new Promise((resolve,reject)=>requests.push({url,init,resolve,reject})),
apply:bytes=>applied.push(bytes),error:()=>errors.push(true)});
return {timers,requests,applied,errors,stop,setState:value=>{state=value;},setClock:value=>{clock=value;},
next:()=>{const [id,timer]=[...timers].find(([,t])=>t.ms!==2500);timers.delete(id);timer.fn();},
answer:(index,cursor='next',status=200)=>requests[index].resolve(new Response(status===204?null:new Uint8Array([1,2]),{status,headers:{'X-Planning-Scene-Cursor':cursor}}))};
}
test('one pending fetch, native cadence, no-op response and cancellation',async()=>{
const f=fixture();assert.equal(f.requests.length,1);
assert.equal(f.timers.size,1); // deadline only: no overlapping polling interval
f.setClock(35);f.answer(0);await settle();
assert.equal(f.applied.length,1);assert.equal([...f.timers.values()][0].ms,65);
f.next();assert.match(f.requests[1].url,/cursor=next/);
f.answer(1,'next',204);await settle();assert.equal(f.applied.length,1);
f.next();f.stop();assert.equal(f.requests[2].init.signal.aborted,true);
f.answer(2);await settle();assert.equal(f.applied.length,1);assert.equal(f.timers.size,0);
});
test('a changed mode or ended run discards the in-flight response and rebases',async()=>{
for(const next of [{active:true,revision:2,options:{mode:'top'}},{active:false,revision:2,options:{mode:'3d'}}]){
const f=fixture();f.setState(next);f.answer(0);await settle();
assert.equal(f.applied.length,0);f.next();assert.match(f.requests[1].url,/base=true/);
f.answer(1);await settle();assert.equal(f.applied.length,1);f.stop();
}
});
test('failure hides stale evidence, repairs geometry but retains admitted camera cursor',async()=>{
const f=fixture();f.answer(0);await settle();f.next();
f.requests[1].reject(new Error('offline'));await settle();
assert.equal(f.errors.length,1);assert.equal([...f.timers.values()][0].ms,500);
f.next();assert.match(f.requests[2].url,/base=true/);assert.match(f.requests[2].url,/cursor=next/);
f.stop();f.answer(2);await settle();assert.equal(f.applied.length,1);
});
test('display changes and discarded responses retain camera identity; reset intent reaches server',async()=>{
const f=fixture();f.answer(0,'admitted-camera');await settle();f.next();
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:0}});
f.answer(1,'discarded-camera');await settle();
assert.equal(f.applied.length,1);f.next();
assert.match(f.requests[2].url,/cursor=admitted-camera/);
assert.match(f.requests[2].url,/base=true/);
assert.match(f.requests[2].url,/reset=0/);
f.answer(2,'clipped');await settle();
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:1}});
f.next();assert.match(f.requests[3].url,/reset=1/);
f.answer(3,'reset');await settle();f.stop();
});
test('unchanged terminal views stop transfer but notice a later revision',async()=>{
const f=fixture();f.setState({active:false,revision:2,options:{mode:'3d'}});
f.answer(0);await settle();f.next();f.answer(1);await settle();f.next();
assert.equal(f.requests.length,2);
f.setState({active:false,revision:3,options:{mode:'3d'}});f.next();
assert.equal(f.requests.length,3);f.stop();f.answer(2);await settle();
});
test('cloud or fit expiring during delivery cannot revive green',async()=>{
for(const [cloud,fit] of [['1.8','1'],['.1','7.8'],['invalid','1']]){
const f=fixture();f.setClock(300);
f.requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
'X-Planning-Cloud-Age':cloud,'X-Planning-Fit-Age':fit}}));
await settle();assert.equal(f.applied.length,0);assert.equal(f.errors.length,1);f.stop();
}
});
test('a live response waits for bounded browser delivery and passes its exact receipt identity',async()=>{
let release,delivery;
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
const timers=new Map(),requests=[];
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
fetcher:(url,init)=>new Promise(resolve=>requests.push({url,init,resolve})),
apply:(_bytes,value)=>{delivery=value;return new Promise(resolve=>{release=resolve;});},error:assert.fail,
});
clock=40;
requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
'X-Planning-Cloud-Age':'.2','X-Planning-Fit-Age':'.1',
'X-Planning-Cloud-Revision':'3','X-Planning-Cloud-Sequence':'4',
'X-Planning-Display-Epoch':'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
'X-Planning-Height-Min':'-1.2','X-Planning-Height-Max':'51.5',
}}));
await settle();
assert.deepEqual(delivery,{presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
heightMinM:-1.2,heightMaxM:51.5});
assert.equal(timers.size,1);
assert.equal([...timers.values()][0].ms,2500);
release();await settle();
assert.equal([...timers.values()][0].ms,60);
stop();
});
test('profile scene can expand after native capture ends and retains Escape',()=>{
const source=readFileSync(new URL('../src/workspaces/spatial/SpatialWorkspace.tsx',import.meta.url),'utf8');
assert.match(source,/pointCloudVisible && \(visualProfile \|\| sourceUrl.trim\(\)\)/);
assert.match(source,/<IconButton\s+className="scene-source-control"\s+label="Развернуть облако точек"/);
assert.match(source,/event.key !== "Escape"/);
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFileSync} from 'node:fs';
let server,Tool,Actions;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({PlanningSceneToolWindow:Tool}=await server.ssrLoadModule('/src/components/missions/PlanningSceneToolWindow.tsx'));
({SpatialToolbarActions:Actions}=await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialToolbarActions.tsx',import.meta.url).pathname));
});
after(async()=>{await server?.close();});
test('scene tools use the bounded modeless window with move, resize and expand controls',()=>{
const html=renderToStaticMarkup(createElement(Tool,{boundsRef:{current:null},title:'Слои',onClose:()=>{}},'CONTROLS'));
assert.match(html,/nodedc-workspace-window/);
assert.match(html,/aria-modal="false"/);
assert.match(html,/Переместить инструмент/);
assert.match(html,/Изменить размер инструмента/);
assert.match(html,/Развернуть инструмент/);
assert.doesNotMatch(html,/nodedc-overlay/);
});
test('spatial toolbar omits source and duplicate planning navigation',()=>{
const html=renderToStaticMarkup(createElement(Actions,{openLayers:()=>{},openDisplay:()=>{},activeTool:'layers'}));
assert.match(html,/Слои/);assert.match(html,/Отображение/);
assert.match(html,/aria-pressed="true"/);assert.doesNotMatch(html,/Движок|Планирование/);
const workspace=readFileSync(new URL('../src/workspaces/missions/PlanningSpatialWorkspace.tsx',import.meta.url),'utf8');
assert.doesNotMatch(workspace,/<Window\s|openSource=|openView\('mission-planner'\)/);
assert.match(workspace,/tools:boundsRef=>tool&&<PlanningSceneToolWindow/);
const viewer=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
assert.match(viewer,/\},\[runId,retry\]\)/); // Presentation never owns viewer lifetime.
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),

Some files were not shown because too many files have changed in this diff Show More