Author SHA1 Message Date
DCCONSTRUCTIONS 9d3329f608 docs: record rover integration closeout and verification 2026-09-25 16:49:52 +03:00
DCCONSTRUCTIONS 399af98841 merge: integrate final rover control with operator simulation workspace 2026-09-25 16:48:42 +03:00
DCCONSTRUCTIONS 71a648fec8 feat(fleet): preserve operator VESC integration before final driver merge 2026-09-25 16:40:46 +03:00
DCCONSTRUCTIONS dad11b47d7 docs(simulation): record polygon experiments and bounded route acceptance 2026-09-25 16:40:46 +03:00
DCCONSTRUCTIONS e627500295 feat(control-station): add AI polygon setup streaming and mission controls 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS f01bd39037 feat(simulation): add Worker AI polygon runtime and terrain navigation 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS a7c64e009d fix(core): keep launchd service logs outside protected Downloads 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS 6bbbd5bf7d docs(rover): record calibration control acceptance and operating boundaries 2026-09-25 16:39:19 +03:00
DCCONSTRUCTIONS bc55901d4d fix(core): serialize planning reports outside the control event loop 2026-09-25 16:37:57 +03:00
DCCONSTRUCTIONS 6d772b29fe feat(control-station): add rover scene telemetry and guarded keyboard control 2026-09-25 16:37:57 +03:00
DCCONSTRUCTIONS 63cbb08ea0 feat(rover): add drive profiles and leased remote command channel 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 53818230f9 feat(node): ship managed environment startup and USB recovery 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 45fb14b206 feat(vesc): integrate native calibration diagnostics and configuration archives 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 24bbaefb00 chore(repo): keep private evidence local and version rover assets with LFS 2026-09-25 16:37:20 +03:00
DCCONSTRUCTIONS 2e5d52521f feat: finalize corrected-route planning and Rerun recording review 2026-09-22 10:10:03 +03:00
DCCONSTRUCTIONS c804d89b18 fix(lab): separate direct device launches from planning profiles 2026-09-21 09:19:14 +03:00
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 020a878915 docs(node): reconcile release status and build provenance for 0.6.11 2026-09-06 01:39:02 +03:00
DCCONSTRUCTIONS 404285419f fix(sensors): align device lamp with identity and refresh to end 2026-09-06 01:18:47 +03:00
DCCONSTRUCTIONS ba0c948ca1 fix(node): show camera connectivity and remove company header marks 2026-09-06 01:05:19 +03:00
DCCONSTRUCTIONS 429b4681fe fix(node): use compact guideline copy and plain setup checkmarks 2026-09-06 00:44:23 +03:00
DCCONSTRUCTIONS 22d69107ba feat(node): push USB changes and preserve sensor setup across sessions 2026-09-06 00:24:48 +03:00
DCCONSTRUCTIONS a8647c4d87 fix(node): verify D455 access and share sensor progress and recording UI 2026-09-05 23:25:11 +03:00
DCCONSTRUCTIONS b1aaa40508 Add D455 sensor host and shared Node/Core preparation surface 2026-09-05 22:27:07 +03:00
DCCONSTRUCTIONS 3616acc648 fix(node): clear consumed invitations after confirmed pairing 2026-09-05 21:37:56 +03:00
DCCONSTRUCTIONS e82d012907 feat(node): pair onboard computers with the Core fleet through UI 2026-09-05 21:16:13 +03:00
DCCONSTRUCTIONS fc545f8440 docs(node): proceed to pairing with clean-install acceptance deferred 2026-09-05 20:31:43 +03:00
DCCONSTRUCTIONS a98ef339de fix(node): show completed setup checks with a timestamp 2026-09-05 20:11:17 +03:00
DCCONSTRUCTIONS 9062126913 fix(node): make environment progress readable from the desktop 2026-09-05 19:54:08 +03:00
DCCONSTRUCTIONS 59e14c5bc0 feat(node): configure system environment through the desktop workflow 2026-09-05 19:46:52 +03:00
DCCONSTRUCTIONS 15bb793e5e fix(node): restore Linux network inventory and expose read failures 2026-09-05 18:47:12 +03:00
DCCONSTRUCTIONS 79911eb316 feat(node): consolidate board system views and plan fleet pairing 2026-09-05 18:35:57 +03:00
DCCONSTRUCTIONS d696842f5d feat(node): package Ubuntu desktop setup and trusted access 2026-09-05 17:27:59 +03:00
DCCONSTRUCTIONS 57f2537af3 Document AI Inference architecture and freeze current baseline 2026-09-05 10:05:01 +03:00
DCCONSTRUCTIONS e56eb42507 docs: inventory Rerun customizations and upstream dependencies 2026-09-05 09:21:28 +03:00
DCCONSTRUCTIONS b2a1b23131 fix(observatory): preserve camera across follow transitions 2026-09-05 08:46:35 +03:00
DCCONSTRUCTIONS cada687173 feat(observatory): ship modular AI inference labs 2026-09-04 17:59:05 +03:00
DCCONSTRUCTIONS eff60e490a docs(observatory): record saved replay evidence and open visual gates 2026-09-03 13:24:32 +03:00
DCCONSTRUCTIONS 9db29bcdc6 feat(observatory): share native result replay and expose saved TGS layers 2026-09-03 13:23:56 +03:00
DCCONSTRUCTIONS 27979854a7 feat(observatory): cache sealed camera and TGS replay on Core 2026-09-03 13:15:07 +03:00
DCCONSTRUCTIONS 3ea8d987a7 docs(observatory): record real M49 publication and restart acceptance 2026-09-03 11:50:32 +03:00
DCCONSTRUCTIONS bd947b49f4 fix(observatory): preserve source identity on unchanged reindex 2026-09-03 11:43:03 +03:00
DCCONSTRUCTIONS 54c8d82884 ops(observatory): install exact recorded-progress agent layers 2026-09-03 11:22:03 +03:00
DCCONSTRUCTIONS bee8552003 feat(observatory): expose attempt-bound recorded progress 2026-09-03 11:17:56 +03:00
DCCONSTRUCTIONS 4e0565eb4b docs(observatory): refresh four-stage recorded-first roadmap 2026-09-03 10:35:48 +03:00
DCCONSTRUCTIONS 44afadb021 docs(observatory): record selector rules and local UI acceptance 2026-09-03 10:16:12 +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
1140 changed files with 157267 additions and 7740 deletions
+2
View File
@@ -10,3 +10,5 @@
*.las binary *.las binary
*.lcc binary *.lcc binary
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/public/rover-scene/*.glb filter=lfs diff=lfs merge=lfs -text
+7
View File
@@ -49,3 +49,10 @@ mqtt.summary.json
# Small synthetic/redacted fixtures under tests/fixtures are allowed. # Small synthetic/redacted fixtures under tests/fixtures are allowed.
!tests/fixtures/**/*.pcap !tests/fixtures/**/*.pcap
!tests/fixtures/**/*.pcapng !tests/fixtures/**/*.pcapng
# Dependency caches may be links into an existing local checkout.
.venv
node_modules
# Private experiment evidence and generated build archives.
/outputs/
+52
View File
@@ -62,6 +62,58 @@ and the boundary between Mission Core and vendor-specific integration code.
- Synthetic or explicitly redacted fixtures may be committed under - Synthetic or explicitly redacted fixtures may be committed under
`tests/fixtures/`. `tests/fixtures/`.
## Onboard environment ownership — owner requirement, 2026-09-24
- Every onboard OS change belongs to the shipped, versioned installer or the
application's environment/device preparation workflow from the first test.
The operator installs the build and configures it in the application; never
require hand-written service files, USB rules, permission fixes or commands.
- This applies to all Node features, including GUI/service autostart and USB
recovery, not just individual camera drivers. Read-only SSH inspection and
bounded artifact-owned build staging remain allowed.
- Detect the distribution, architecture and required capabilities. Maintain
explicit supported environment profiles; do not claim arbitrary Linux
compatibility from a qualified Ubuntu build. Preserve foreign configuration,
report unsupported features, make preparation repeatable, and ship rollback.
- Do not add USB administration controls or port resets to inventory refresh.
Automatic startup recovery may retry terminal enumeration failures only;
preserve enumerated devices and active companion ports. Device identity and
assignments must survive changes of USB port and tty number.
## Insta360 and clean-host installation — owner requirement, 2026-09-08
- The current X4 starting point is USB enumeration only. SDK installation,
camera initialization and live video have not been accepted.
- Every Ubuntu change needed for Insta360 must execute through the shipped
installer or the application's versioned device-preparation workflow from
the first board experiment. Do not repair the board with an ad-hoc apt/pip
install, copied library, chmod, environment override, service edit or root
invocation and promise to package it later. Fix the product artifact first,
then rerun that artifact. Read-only engineering inspection remains allowed.
- Document every relevant action and its before/after evidence in
`docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version,
owning installer step, dependencies, idempotency, failure behavior and rollback.
Keep private logs/identifiers/media out of Git and redact their public summary.
- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency
closure on the declared clean OS image and actual USB operation separately;
installer, preparation and GUI acceptance must use the same shipped code.
- X4 is operator live video and camera control (settings, photo, recording
start/stop and file access), per the owner’s subsequent scope expansion.
Preview and recording are separate operations. No stitching, AI, navigation
or vehicle-control integration is admitted. Model support and physical device instances are separate: several
cameras of the same model must retain independent identity and lifecycle.
- Follow the scoped implementation plan in
`docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan,
not evidence that SDK compatibility, hardware capacity or installation passed.
- Subsequent owner direction: the existing Ubuntu Mini is the available build
and qualification machine; do not depend on Worker 006 or another host for
this X4 work. Its existing GCC may compile through a versioned build artifact
in bounded temporary staging. This is a build step, not runtime installation:
no system changes, SDK execution or camera access during compilation. The
final installer must carry the compiled payload so a clean operator Ubuntu
does not inherit an undeclared compiler/build-cache prerequisite. All board
staging/build/install/test actions remain documented and artifact-owned.
## Implementation order ## Implementation order
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
+1
View File
@@ -0,0 +1 @@
@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/
+24
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "@nodedc/mission-core-control-station", "name": "@nodedc/mission-core-control-station",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react", "@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
@@ -14,6 +15,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3", "@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1", "meshoptimizer": "1.1.1",
"playcanvas": "2.21.4", "playcanvas": "2.21.4",
@@ -898,6 +900,19 @@
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"link": true "link": true
}, },
"node_modules/@nvidia/ov-web-rtc": {
"version": "6.7.0",
"resolved": "https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/@nvidia/ov-web-rtc/-/@nvidia/ov-web-rtc-6.7.0.tgz",
"integrity": "sha512-Knt393yvuI1tFmQgJM7VuMlVKDh5GyuKq8fAIarNLyY7Ucxw+lSY/72ceWR+3HyCnNtL39J0DZ27dn9riqpGWw==",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/@rerun-io/web-viewer": { "node_modules/@rerun-io/web-viewer": {
"version": "0.36.3", "version": "0.36.3",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz", "resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
@@ -1614,6 +1629,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+3
View File
@@ -5,6 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"postinstall": "node scripts/install-rerun-navigation.mjs",
"prebuild": "node scripts/install-rerun-navigation.mjs",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "node --test test/*.test.mjs", "test:unit": "node --test test/*.test.mjs",
@@ -17,6 +19,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3", "@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1", "meshoptimizer": "1.1.1",
"playcanvas": "2.21.4", "playcanvas": "2.21.4",
Binary file not shown.
@@ -0,0 +1,39 @@
{
"source": "DCD-006_rover_v020.blend",
"source_sha256": "c49a0470974bbe65b4e65b4b5c06cb887730715a1ddfa231b86426fed6de8f79",
"model": "DCD-006 v020 complete rover",
"source_object_count": 1426,
"render_mesh_count": 1,
"original_bounds": [
[
-0.527999997138977,
-0.45249998569488525,
0.0
],
[
0.6350000500679016,
0.45249998569488525,
0.734000027179718
]
],
"dimensions_m": [
1.1630001068115234,
0.9049999713897705,
0.734000027179718
],
"translation_m": [
-0.05350002646446228,
-0.0,
-0.0
],
"glb_bytes": 54960576,
"glb_sha256": "15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79",
"material_source_sha256": "ac50e29fe9fe7ab4012628c52fce30e262e9e0bba4a4d49abe59a90c84391b2e",
"material_rules": {
"metal": 1,
"rubber": 2,
"steel": 6,
"paint": 3
},
"source_saved": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Синхронизированная запись</title>
<style>
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<div id="viewer"></div>
<script type="module" src="/src/components/rerun/isolatedRerunEntry.ts"></script>
</body>
</html>
@@ -0,0 +1,59 @@
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
const manifest = JSON.parse(readFileSync(resolve(vendorRoot, "navigation-build.json"), "utf8"));
const installed = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
if (installed.version !== "0.36.3" || manifest.upstreamVersion !== installed.version) {
throw new Error("Rerun navigation requires the audited 0.36.3 package; rebase before upgrading");
}
if (sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))) !== manifest.patchSha256) {
throw new Error("Rerun navigation source patch identity mismatch");
}
const eyeType = "{ position: [number, number, number]; lookTarget: [number, number, number]; eyeUp: [number, number, number] } | null";
const additions = {
"index.js": {
marker: " get_active_recording_id() {",
code: ` get_camera_eye() {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
},
"index.ts": {
marker: " get_active_recording_id(): string | null {",
code: ` get_camera_eye(): ${eyeType} {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
},
"index.d.ts": {
marker: " get_active_recording_id(): string | null;",
code: ` get_camera_eye(): ${eyeType};\n`,
},
};
// Validate the complete set before changing any installed file. A package
// upgrade, corrupt artifact or independently modified wrapper fails closed.
const writes = [];
for (const [name, identity] of Object.entries(manifest.files)) {
const current = readFileSync(resolve(packageRoot, name));
let output;
if (additions[name]) {
const { marker, code } = additions[name];
const source = current.toString("utf8");
const original = source.includes(code) ? source.replace(code, "") : source;
if (sha(original) !== identity.upstreamSha256 || original.split(marker).length !== 2) {
throw new Error(`Unexpected upstream wrapper: ${name}`);
}
output = Buffer.from(original.replace(marker, code + marker));
} else {
output = readFileSync(resolve(vendorRoot, identity.artifact));
if (sha(output) !== identity.sha256) throw new Error(`Corrupt navigation artifact: ${name}`);
if (![identity.upstreamSha256, identity.sha256].includes(sha(current))) {
throw new Error(`Unexpected installed runtime: ${name}`);
}
}
writes.push([resolve(packageRoot, name), output]);
}
for (const [path, output] of writes) writeFileSync(path, output);
console.log("Installed NODE.DC Rerun 0.36.3 navigation/v1 (native camera, one renderer).");
+75 -218
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
AdminNavigationPanel, AdminNavigationPanel,
@@ -5,18 +7,14 @@ import {
ApplicationPanel, ApplicationPanel,
ApplicationShell, ApplicationShell,
Button, Button,
Checker,
ColorField,
ControlRow, ControlRow,
HeaderNavigation, HeaderNavigation,
HeaderProfile, HeaderProfile,
HeaderWorkspace,
Icon, Icon,
Inspector, Inspector,
RangeControl,
Select,
StatusBadge, StatusBadge,
TextField, TextField,
ToastStack,
UserProfileMenu, UserProfileMenu,
Window, Window,
WindowFooterActions, WindowFooterActions,
@@ -30,6 +28,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector"; import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
import { useComputeContourSettings } from "./components/system/useComputeContourSettings"; import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
import { useApplicationPanelActions } from "./components/useApplicationPanelActions"; import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
import { useSessionOverviewMode } from "./core/observation/useSessionOverview";
import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace";
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings"; import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
@@ -50,6 +50,7 @@ import type {
} from "./core/observation/sessionArchive"; } from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission"; import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile"; import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget"; import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
import { resolvePolygonRunRoute } from "./core/polygon/runArchive"; import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
import { import {
@@ -67,33 +68,18 @@ import {
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation"; import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
import { import {
defaultSceneSettings, defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings, type SceneSettings,
} from "./sceneSettings"; } from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace"; import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
import { WorkspaceRenderer } from "./workspaces/Workspaces"; import { WorkspaceRenderer } from "./workspaces/Workspaces";
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader"; import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
import "./styles/scene-windows.css"; import "./styles/scene-windows.css";
type SceneToolWindowId = "sources" | "display" | "layers"; type SceneToolWindowId = "sources" | "display" | "layers";
const viewerSettingsQuietPeriodMs = 750; 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 { interface LivePerceptionLayers {
detections2d: boolean; detections2d: boolean;
segmentation: boolean; segmentation: boolean;
@@ -170,7 +156,9 @@ export default function App() {
polygonDatasetRoute.active ? "data" : null, polygonDatasetRoute.active ? "data" : null,
); );
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false); const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
const [sourceUrl, setSourceUrl] = useState(""); const [sourceUrl, setSourceUrl] = useState("");
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null); const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false); const [replayTransitioning, setReplayTransitioning] = useState(false);
@@ -195,6 +183,7 @@ export default function App() {
const appliedProfileKeyRef = useRef<string | null>(null); const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings); const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings); const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
const closeDisplayRef = useRef<() => void>(() => {});
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings); const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const viewerSettingsCommitTimerRef = useRef<number | null>(null); const viewerSettingsCommitTimerRef = useRef<number | null>(null);
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings); const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
@@ -242,6 +231,13 @@ export default function App() {
); );
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings; runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayPresented; replayActiveRef.current = replayPresented;
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
sceneSettingsRef.current = settings;
displayDraftRef.current = settings;
confirmedSceneSettingsRef.current = settings;
setSceneSettings(settings);
setDisplayDraft(settings);
});
useEffect(() => { useEffect(() => {
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return; if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
@@ -324,6 +320,7 @@ export default function App() {
const remote = runtime.state?.viewerSettings; const remote = runtime.state?.viewerSettings;
if ( if (
!remote || !remote ||
replayActiveRef.current ||
workspaceLayoutProfile.profile || workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy() sceneSettingsCommitterRef.current?.isBusy()
) return; ) return;
@@ -346,7 +343,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
const profile = workspaceLayoutProfile.profile; const profile = workspaceLayoutProfile.profile;
if (!profile) return; if (!profile || selectedRecordedSessionIdRef.current) return;
if (viewerSettingsCommitTimerRef.current !== null) { if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current); window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null; viewerSettingsCommitTimerRef.current = null;
@@ -417,7 +414,10 @@ export default function App() {
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => { const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
if (windowId === "sources") setSourceWindowOpen(false); if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false); if (windowId === "display") {
setDisplayWindowOpen(false);
closeDisplayRef.current();
}
if (windowId === "layers") setLayerInspectorOpen(false); if (windowId === "layers") setLayerInspectorOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId)); setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []); }, []);
@@ -449,9 +449,10 @@ export default function App() {
} }
}; };
const openView = (viewId: string) => { const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
const definition = workspaceById(viewId); const definition = workspaceById(viewId);
if (!definition) return; if (!definition) return;
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
setActiveRoot(definition.root); setActiveRoot(definition.root);
workspace.openView(viewId); workspace.openView(viewId);
}; };
@@ -477,14 +478,22 @@ export default function App() {
const next = { ...displayDraftRef.current, ...patch }; const next = { ...displayDraftRef.current, ...patch };
displayDraftRef.current = next; displayDraftRef.current = next;
setDisplayDraft(next); setDisplayDraft(next);
sessionDisplayProfile.edited();
if (viewerSettingsCommitTimerRef.current !== null) { if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current); window.clearTimeout(viewerSettingsCommitTimerRef.current);
} }
// Preview and persistence are separate. An open inspector must not freeze
// either its controls or the shared accumulation slider below the scene.
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(next);
return;
}
viewerSettingsCommitTimerRef.current = window.setTimeout(() => { viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
viewerSettingsCommitTimerRef.current = null; viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current); sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, viewerSettingsQuietPeriodMs); }, viewerSettingsQuietPeriodMs);
}, []); }, [sessionDisplayProfile.edited]);
const flushDisplaySettings = useCallback(() => { const flushDisplaySettings = useCallback(() => {
if (viewerSettingsCommitTimerRef.current === null) return; if (viewerSettingsCommitTimerRef.current === null) return;
@@ -494,8 +503,15 @@ export default function App() {
}, []); }, []);
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => { const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
sessionDisplayProfile.edited();
commitDisplaySettings({ ...displayDraftRef.current, ...patch }); commitDisplaySettings({ ...displayDraftRef.current, ...patch });
}, [commitDisplaySettings]); }, [commitDisplaySettings, sessionDisplayProfile.edited]);
closeDisplayRef.current = () => {
const settings = {...displayDraftRef.current};
commitDisplaySettings(settings);
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
};
const openDisplay = () => { const openDisplay = () => {
if (!sceneWorkspaceActive) return; if (!sceneWorkspaceActive) return;
@@ -629,7 +645,11 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [layoutSaveNotice]); }, [layoutSaveNotice]);
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
const contentActions = useApplicationPanelActions({ const contentActions = useApplicationPanelActions({
onAddVehicle,
definition: activeDefinition, definition: activeDefinition,
refreshRuntime: runtime.refresh, refreshRuntime: runtime.refresh,
resetConnectionScenario: runtime.resetConnectionScenario, resetConnectionScenario: runtime.resetConnectionScenario,
@@ -637,6 +657,7 @@ export default function App() {
saveWorkspaceLayout, saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving", workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityActions: computeContourSettings.utilityActions, systemUtilityActions: computeContourSettings.utilityActions,
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
}); });
const header = ( const header = (
@@ -645,8 +666,6 @@ export default function App() {
brandHref="/" brandHref="/"
brandLabel="NODEDC MISSION CORE" brandLabel="NODEDC MISSION CORE"
center={ center={
<>
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
<HeaderNavigation <HeaderNavigation
label="Архитектурные блоки пункта управления" label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined} value={activeRoot ?? undefined}
@@ -656,7 +675,6 @@ export default function App() {
}))} }))}
onChange={selectRoot} onChange={selectRoot}
/> />
</>
} }
right={ right={
<HeaderProfile> <HeaderProfile>
@@ -802,7 +820,9 @@ export default function App() {
settleRecordedReplaySwitch(outcome)} settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete} onDeleteBegin={releaseRecordedReplayForDelete}
/> />
) : activeDefinition.kind === "datasets" ? ( ) : activeDefinition.kind === "missions" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge> <StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? ( ) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control laboratoryAnnotation.control
@@ -820,14 +840,28 @@ export default function App() {
utilityActions={contentActions} utilityActions={contentActions}
onClose={workspace.closeView} onClose={workspace.closeView}
> >
<PlanningCaptureGuard
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
onResume={() => openView('local-device', 'planning')}
>
{activeDefinition.kind === "device" ? ( {activeDefinition.kind === "device" ? (
<DeviceWorkspace launchProfile === 'planning' ? <PlanningConnectionWindow>
onOpenSpatialScene={() => openView("spatial-scene")} <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
</PlanningConnectionWindow> : <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource} onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/> />
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : ( ) : (
<WorkspaceRenderer <WorkspaceRenderer
launchProfile={launchProfile}
definition={activeDefinition} definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
state={activeRuntimeState} state={activeRuntimeState}
backendStatus={runtime.backendStatus} backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl} sourceUrl={effectiveSourceUrl}
@@ -868,6 +902,7 @@ export default function App() {
}} }}
/> />
)} )}
</PlanningCaptureGuard>
</ApplicationPanel> </ApplicationPanel>
) : null} ) : null}
/> />
@@ -1029,133 +1064,12 @@ export default function App() {
onPointerDown={() => activateSceneWindow("display")} onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("display")} onClose={() => closeSceneWindow("display")}
> >
<Inspector <SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
defaultOpen={["points"]} commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
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>
),
},
]}
/>
</Window> </Window>
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
onDismiss={sessionDisplayProfile.dismissError} />
<Window <Window
open={sceneWorkspaceActive && layerInspectorOpen} open={sceneWorkspaceActive && layerInspectorOpen}
@@ -1180,64 +1094,7 @@ export default function App() {
</span> </span>
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p> <p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
</div> </div>
<Inspector <SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
defaultOpen={["geometry"]}
singleOpen
sections={[
{
id: "geometry",
label: "Геометрия",
description: "Облако точек и путь",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div>
</div>
),
},
{
id: "frames",
label: "Координаты и камеры",
description: "Преобразования и области обзора",
content: (
<div className="inspector-control-stack">
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
</div>
</div>
),
},
{
id: "perception",
label: "Объекты и маски",
description: "Ожидают внешние обработчики",
content: (
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
</div>
</div>
),
},
]}
/>
</Window> </Window>
</> </>
); );
@@ -1,92 +1 @@
import { useEffect, useMemo, useState } from "react"; export { EnvironmentBackgroundMedia } from "@nodedc/ui-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>
);
}
@@ -1,248 +1 @@
import { useEffect, useMemo, useRef, useState } from "react"; export { EnvironmentMediaPlaylistEditor } from "@nodedc/ui-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>
);
}
@@ -1,358 +1,25 @@
import { useEffect, useMemo, useState } from "react"; import { EnvironmentSettingsWindow as SharedEnvironmentSettingsWindow } from "@nodedc/ui-react";
import { import { environmentSurfaceIds, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia } from "../core/environment/environmentSettings";
Button, import { roots, workspaces, workspacesForRoot } from "../productModel";
FeatureSettingsWindow,
Select,
SettingsCard,
Switch,
TextAreaField,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
import { interface Props {
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 {
open: boolean; open: boolean;
settings: EnvironmentSettings; settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error"; state: "loading" | "ready" | "saving" | "error";
error: string | null; error: string | null;
onClose: () => void; onClose: () => void;
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>; onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: ( onUpload: (surfaceId: EnvironmentSurfaceId, itemId: string, file: File) => Promise<UploadedEnvironmentMedia>;
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
} }
const surfaces = environmentSurfaceIds.map(id => ({
function patchPage( id, home: id === "home", description: id === "home" ? "Главная страница продукта" : `Стартовая страница раздела «${roots.find(root => root.id === id)?.label}»`,
draft: EnvironmentSettings, actions: id === "home" ? workspaces.filter(item => !item.internalOnly) : workspacesForRoot(id),
surfaceId: EnvironmentSurfaceId, }));
patch: Partial<EnvironmentPage>, export function EnvironmentSettingsWindow(props: Props) {
): EnvironmentSettings { return <SharedEnvironmentSettingsWindow {...props} productName="Mission Core" surfaces={surfaces} initialSurfaceId="fleet"
return { onSave={draft => props.onSave({ ...draft, pages: Object.fromEntries(environmentSurfaceIds.map(id => [id, draft.pages[id]])) as EnvironmentSettings["pages"] })}
...draft, onUpload={(id, itemId, file) => {
pages: { if (!environmentSurfaceIds.includes(id as EnvironmentSurfaceId)) throw new Error("Страница окружения недоступна.");
...draft.pages, return props.onUpload(id as EnvironmentSurfaceId, itemId, file);
[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>
);
} }
@@ -1,5 +1,6 @@
import { useLayoutEffect, useState, type RefObject } from "react"; import type {RefObject} from "react";
import { WorkspaceWindow } from "@nodedc/ui-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 { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts"; import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
@@ -10,60 +11,6 @@ import type {
RecordedCameraAdmissionState, RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission"; } 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({ export function FloatingObservationWindow({
source, source,
index, index,
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
state: RecordedCameraAdmissionState, state: RecordedCameraAdmissionState,
) => void; ) => void;
}) { }) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); return <FloatingMediaWindow title={source.label} subtitle={source.description}
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
useLayoutEffect(() => { active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
const element = boundsRef.current; onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
if (!element) return; 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>}>
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}`}
>
<ObservationMedia <ObservationMedia
source={source} source={source}
playback={playback} playback={playback}
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
recordedAdmissionKey={recordedAdmissionKey} recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange} 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 { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentPage } from "../core/environment/environmentSettings"; import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel"; import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation"; import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
export interface LandingStageProps { export interface LandingStageProps {
root: RootDefinition | null; root: RootDefinition | null;
@@ -25,40 +24,10 @@ export function LandingStage({
quickActions, quickActions,
onOpenWorkspace, onOpenWorkspace,
}: LandingStageProps) { }: LandingStageProps) {
const { background } = page; return <SharedLandingStage page={page} pageId={root?.id ?? "home"}
const hasMedia = background.enabled && background.items.some( actions={quickActions.map(workspace => ({ id: workspace.id, label: workspace.label, icon: workspace.icon, onSelect: () => onOpenWorkspace(workspace.id) }))}
(item) => item.url && item.mediaKind, status={<>
); <div>
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>
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span> <span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge> <StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</div> </div>
@@ -67,12 +36,9 @@ export function LandingStage({
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge> <StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
</div> </div>
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p> <p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
</div> </>}
footer={<>
<footer className="landing-stage__footer"> <span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</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; 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_MEDIA_TIMEOUT_MS = 8_000;
export const CAMERA_FIRST_PLAYABLE_FRAME_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 // 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; // main-thread stall. Keep one bounded append margin above that complete batch;
// crossing either limit replaces the MSE epoch instead of dropping fragments. // crossing either limit replaces the MSE epoch instead of dropping fragments.
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
let onBufferError: (() => void) | null = null; let onBufferError: (() => void) | null = null;
let objectUrl = ""; let objectUrl = "";
let retryTimer: number | undefined; let retryTimer: number | undefined;
let bufferingNoticeTimer: number | undefined;
let receivedMedia = false; let receivedMedia = false;
let failed = false; let failed = false;
let playingReported = false; let playingReported = false;
@@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({
); );
const queue: ArrayBuffer[] = []; const queue: ArrayBuffer[] = [];
let queuedBytes = 0; let queuedBytes = 0;
const clearBufferingNotice = () => {
if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer);
bufferingNoticeTimer = undefined;
};
const fail = (copy: string) => { const fail = (copy: string) => {
if (!transportIsCurrent() || failed) return; if (!transportIsCurrent() || failed) return;
startupWatchdog?.clear(); startupWatchdog?.clear();
clearBufferingNotice();
failed = true; failed = true;
transportHealthyRef.current = false; transportHealthyRef.current = false;
recoveryPendingAuthorityRef.current = null; recoveryPendingAuthorityRef.current = null;
@@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({
return; return;
} }
startupWatchdog?.clear(); startupWatchdog?.clear();
clearBufferingNotice();
failed = true; failed = true;
transportHealthyRef.current = false; transportHealthyRef.current = false;
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id); const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
@@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({
const onPlaying = () => { const onPlaying = () => {
if (!transportIsCurrent() || failed) return; if (!transportIsCurrent() || failed) return;
clearBufferingNotice();
startupWatchdog?.markPlaying(); startupWatchdog?.markPlaying();
transportHealthyRef.current = true; transportHealthyRef.current = true;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id); 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("playing", onPlaying);
video.addEventListener("waiting", onWaiting);
video.addEventListener("stalled", onWaiting);
const appendNext = () => { const appendNext = () => {
if ( if (
@@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({
disposed = true; disposed = true;
transportHealthyRef.current = false; transportHealthyRef.current = false;
startupWatchdog?.clear(); startupWatchdog?.clear();
clearBufferingNotice();
if (retryTimer !== undefined) window.clearTimeout(retryTimer); if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0; queue.length = 0;
socket?.close(1000, "Browser preview transport replaced or hidden"); socket?.close(1000, "Browser preview transport replaced or hidden");
video.removeEventListener("playing", onPlaying); video.removeEventListener("playing", onPlaying);
video.removeEventListener("waiting", onWaiting);
video.removeEventListener("stalled", onWaiting);
mediaSource.removeEventListener("sourceopen", onSourceOpen); mediaSource.removeEventListener("sourceopen", onSourceOpen);
if (sourceBuffer && onBufferUpdateEnd) { if (sourceBuffer && onBufferUpdateEnd) {
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd); sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
@@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({
{status !== "playing" ? ( {status !== "playing" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite"> <div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} /> <Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong> <strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
<span>{message}</span> <span>{message}</span>
{status === "error" ? ( {status === "error" ? (
<button <button
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null); const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({ const sessions = useObservationSessions({
limit, limit,
scope: "source", scope: "standalone",
replayEnabled: blockedReason === null, replayEnabled: blockedReason === null,
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
@@ -1,9 +1,9 @@
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react"; import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
import { Icon } from "@nodedc/ui-react";
import type { import type {
ObservationSourceAvailability,
ObservationSourceDescriptor, ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts"; } from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer"; import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import { import {
@@ -16,28 +16,6 @@ import type {
} from "../core/observation/recordedSessionAdmission"; } from "../core/observation/recordedSessionAdmission";
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery"; 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({ export function ObservationMedia({
source, source,
playback, playback,
@@ -159,105 +137,3 @@ export function ObservationMedia({
</div> </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"; export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
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")}`;
}
@@ -1,6 +1,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createIsolatedRerunHost } from "./rerun/isolatedRerunHost";
import { keepRecordedBlueprintSession } from "../core/observation/recordedBlueprintLifecycle";
import {
RECORDED_RERUN_ORBITAL_EYE,
RECORDED_RERUN_PLAN_EYE,
type RecordedRerunCameraEye,
} from "./rerun/recordedRerunCameraJournal";
import type { SceneSettings } from "../sceneSettings"; import type { SceneSettings } from "../sceneSettings";
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
import { import {
advanceLiveReceiverOpenWatchdog, advanceLiveReceiverOpenWatchdog,
advanceLiveReceiverWatchdog, advanceLiveReceiverWatchdog,
@@ -123,6 +131,7 @@ export interface RerunViewportProps {
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void; onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
sceneSettings?: Pick< sceneSettings?: Pick<
SceneSettings, SceneSettings,
| "pointDecimationPercent"
| "accumulationSeconds" | "accumulationSeconds"
| "showGrid" | "showGrid"
| "showPoints" | "showPoints"
@@ -138,6 +147,11 @@ export interface RerunViewportProps {
interface RerunBlueprintChannel { interface RerunBlueprintChannel {
endpointUrl: string; endpointUrl: string;
cameraContract?: string | null;
appliedFollowTrajectory?: boolean | null;
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
getCameraEye?: () => RecordedRerunCameraEye | null;
getCurrentTimeNs?: () => number | null;
channel: { channel: {
readonly ready: boolean; readonly ready: boolean;
send_rrd: (rrdBytes: Uint8Array) => void; send_rrd: (rrdBytes: Uint8Array) => void;
@@ -166,6 +180,13 @@ interface RecordedRerunIdentity {
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/; const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
const LAB_RECORDED_REPLAY_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-replay\.rrd$/; const LAB_RECORDED_REPLAY_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-replay\.rrd$/;
const PORTABLE_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/portable-results\/(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
const COMPOSITION_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/ai-composition-runs\/ai-composition-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
export function isRecordedRrdSource(path: string): boolean {
return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path)
|| PORTABLE_RECORDED_REPLAY_PATH.test(path) || COMPOSITION_RECORDED_REPLAY_PATH.test(path);
}
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/; const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/; const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
const LAB_RECORDED_PERCEPTION_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-overlay\.rrd$/; const LAB_RECORDED_PERCEPTION_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-overlay\.rrd$/;
@@ -230,8 +251,7 @@ export function resolveRecordedViewerSourceUrl(
const expectedViewerSourceUrl = `${descriptor.sourceUrl}?generation=${descriptor.sha256}`; const expectedViewerSourceUrl = `${descriptor.sourceUrl}?generation=${descriptor.sha256}`;
const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`); const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`);
if ( if (
!(RECORDED_RRD_PATH.test(descriptor.sourceUrl) !isRecordedRrdSource(descriptor.sourceUrl) ||
|| LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) ||
descriptor.viewerSourceUrl !== expectedViewerSourceUrl || descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
endpoint.origin !== base.origin || endpoint.origin !== base.origin ||
endpoint.pathname !== descriptor.sourceUrl || endpoint.pathname !== descriptor.sourceUrl ||
@@ -262,7 +282,9 @@ export function resolveRecordedBlueprintUrl(
if (explicitSourceUrl !== undefined) { if (explicitSourceUrl !== undefined) {
const explicit = explicitSourceUrl.trim(); const explicit = explicitSourceUrl.trim();
if ( if (
!LAB_RECORDED_REPLAY_PATH.test(normalized) !(LAB_RECORDED_REPLAY_PATH.test(normalized)
|| PORTABLE_RECORDED_REPLAY_PATH.test(normalized)
|| COMPOSITION_RECORDED_REPLAY_PATH.test(normalized))
|| !RECORDED_BLUEPRINT_PATH.test(explicit) || !RECORDED_BLUEPRINT_PATH.test(explicit)
) return null; ) return null;
const endpoint = new URL(explicit, `${base.origin}/`); const endpoint = new URL(explicit, `${base.origin}/`);
@@ -388,7 +410,14 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory = false, followTrajectory = false,
semanticLayer, semanticLayer,
unifiedPerception, unifiedPerception,
unifiedCameraShare = 0.46,
planView = false, planView = false,
cameraEye,
eyeRelativeToTracking = false,
currentTimeNs,
reactivateUpdates = false,
onCameraMaxOrbitalRadius,
displayPointBank,
perceptionLayers = { perceptionLayers = {
enabled: false, enabled: false,
detections2d: false, detections2d: false,
@@ -405,13 +434,24 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory?: boolean; followTrajectory?: boolean;
semanticLayer?: "city" | "vegetation"; semanticLayer?: "city" | "vegetation";
unifiedPerception?: boolean; unifiedPerception?: boolean;
unifiedCameraShare?: number;
planView?: boolean; planView?: boolean;
cameraEye?: RecordedRerunCameraEye;
eyeRelativeToTracking?: boolean;
currentTimeNs?: number | null;
reactivateUpdates?: boolean;
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
displayPointBank?: string | null;
perceptionLayers?: RecordedPerceptionLayers; perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch; fetcher?: typeof globalThis.fetch;
}, },
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const resolvedUnifiedPerception = const resolvedUnifiedPerception =
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial"); unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
// Rerun exposes its interpolated TimeReal cursor as f64 nanoseconds, even
// when paused after seeking. The API needs an integer pose-query timestamp.
// Quantize at this boundary; never round the viewer's actual playback cursor.
const requestTimeNs = currentTimeNs == null ? currentTimeNs : Math.round(currentTimeNs);
const base = new URL(origin); const base = new URL(origin);
const endpoint = new URL(endpointUrl, base.origin); const endpoint = new URL(endpointUrl, base.origin);
if ( if (
@@ -421,10 +461,8 @@ export async function fetchRecordedBlueprintRrd(
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) || !RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
!Number.isFinite(settings.accumulationSeconds) || !Number.isFinite(settings.accumulationSeconds) ||
settings.accumulationSeconds < 0 || settings.accumulationSeconds < 0 ||
settings.accumulationSeconds > 3600 ||
!Number.isFinite(settings.pointSize) || !Number.isFinite(settings.pointSize) ||
settings.pointSize < 0.1 || settings.pointSize < 0.1 ||
settings.pointSize > 32 ||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) || !["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) || !["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) || !/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
@@ -432,12 +470,28 @@ export async function fetchRecordedBlueprintRrd(
![0, 1].includes(viewResetGeneration) || ![0, 1].includes(viewResetGeneration) ||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) || (semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
typeof resolvedUnifiedPerception !== "boolean" || typeof resolvedUnifiedPerception !== "boolean" ||
!Number.isFinite(unifiedCameraShare) ||
unifiedCameraShare < 0.1 ||
unifiedCameraShare > 0.9 ||
typeof planView !== "boolean" || typeof planView !== "boolean" ||
typeof eyeRelativeToTracking !== "boolean" ||
typeof reactivateUpdates !== "boolean" ||
(eyeRelativeToTracking && (cameraEye === undefined || currentTimeNs == null)) ||
(cameraEye !== undefined && [
...cameraEye.position,
...cameraEye.lookTarget,
...cameraEye.eyeUp,
].some((value) => !Number.isFinite(value))) ||
(currentTimeNs !== undefined && currentTimeNs !== null && (
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
)) ||
[ [
perceptionLayers.enabled, perceptionLayers.enabled,
perceptionLayers.cameraImage ?? true,
perceptionLayers.detections2d, perceptionLayers.detections2d,
perceptionLayers.segmentation, perceptionLayers.segmentation,
perceptionLayers.cuboids3d, perceptionLayers.cuboids3d,
perceptionLayers.costmap ?? false,
].some((value) => typeof value !== "boolean") || ].some((value) => typeof value !== "boolean") ||
identity.applicationId !== "nodedc_mission_core_recorded" || identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) ||
@@ -456,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
application_id: identity.applicationId, application_id: identity.applicationId,
recording_id: identity.recordingId, recording_id: identity.recordingId,
blueprint_session_id: blueprintSessionId, blueprint_session_id: blueprintSessionId,
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
accumulation_seconds: settings.accumulationSeconds, accumulation_seconds: settings.accumulationSeconds,
show_grid: settings.showGrid, show_grid: settings.showGrid,
show_points: settings.showPoints, show_points: settings.showPoints,
@@ -468,11 +523,27 @@ export async function fetchRecordedBlueprintRrd(
view_reset_generation: viewResetGeneration, view_reset_generation: viewResetGeneration,
follow_trajectory: followTrajectory, follow_trajectory: followTrajectory,
unified_perception: resolvedUnifiedPerception, unified_perception: resolvedUnifiedPerception,
unified_camera_share: unifiedCameraShare,
semantic_layer: semanticLayer ?? null, semantic_layer: semanticLayer ?? null,
plan_view: planView, plan_view: planView,
eye_position: cameraEye?.position ?? null,
eye_look_target: cameraEye?.lookTarget ?? null,
eye_up: cameraEye?.eyeUp ?? null,
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
current_time_ns: requestTimeNs,
}),
show_detections_2d: perceptionLayers.detections2d, show_detections_2d: perceptionLayers.detections2d,
show_camera_image: perceptionLayers.cameraImage ?? true,
show_segmentation: perceptionLayers.segmentation, show_segmentation: perceptionLayers.segmentation,
show_cuboids_3d: perceptionLayers.cuboids3d, show_cuboids_3d: perceptionLayers.cuboids3d,
...(perceptionLayers.costmap === undefined ? {} : {
show_costmap: perceptionLayers.costmap,
reactivate_updates: true,
}),
...(reactivateUpdates && perceptionLayers.costmap === undefined
? { reactivate_updates: true }
: {}),
}), }),
signal, signal,
}); });
@@ -496,9 +567,32 @@ export async function fetchRecordedBlueprintRrd(
) { ) {
throw new Error("Invalid recorded blueprint RRD"); throw new Error("Invalid recorded blueprint RRD");
} }
const maxOrbitalRadius = Number(
response.headers.get("X-MissionCore-Camera-Max-Orbital-Radius"),
);
if (Number.isFinite(maxOrbitalRadius) && maxOrbitalRadius >= 0.02) {
onCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
return payload; return payload;
} }
export function recordedCameraJournalContract(
{
activeView,
viewResetGeneration,
planView,
}: {
activeView: RecordedRerunView;
viewResetGeneration: 0 | 1;
planView: boolean;
followTrajectory: boolean;
},
): string {
// Following uses the native eye snapshot, never the startup preset.
// Plan/3D and explicit reset are the only preset transitions.
return [activeView, viewResetGeneration, planView].join(":");
}
export async function fetchRecordedPerceptionRrd( export async function fetchRecordedPerceptionRrd(
endpointUrl: string, endpointUrl: string,
identity: RecordedRerunIdentity, identity: RecordedRerunIdentity,
@@ -669,7 +763,15 @@ export async function probeRecordedPerceptionViewerSource(
return { sourceUrl: endpoint.href, byteLength: declaredLength }; 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, profile,
onStatusChange, onStatusChange,
onSelectionChange, onSelectionChange,
@@ -679,7 +781,7 @@ export function RerunViewport({
onPerceptionLoadChange, onPerceptionLoadChange,
onPointColorLoadChange, onPointColorLoadChange,
}: RerunViewportProps) { }: 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 liveProfile = profile.kind === "live-acquisition" ? profile : null;
const sourceUrl = profile.sourceUrl; const sourceUrl = profile.sourceUrl;
const recordedArtifact = recordedProfile?.artifact ?? null; const recordedArtifact = recordedProfile?.artifact ?? null;
@@ -707,6 +809,7 @@ export function RerunViewport({
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl; const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
const recordedSemanticLayer = recordedProfile?.semanticLayer; const recordedSemanticLayer = recordedProfile?.semanticLayer;
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false; const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
const recordedUnifiedCameraShare = recordedProfile?.unifiedCameraShare ?? 0.46;
const recordedPlanView = recordedProfile?.planView ?? false; const recordedPlanView = recordedProfile?.planView ?? false;
const recordedPerceptionRetryGeneration = const recordedPerceptionRetryGeneration =
recordedProfile?.perceptionRetryGeneration ?? 0; recordedProfile?.perceptionRetryGeneration ?? 0;
@@ -739,6 +842,7 @@ export function RerunViewport({
const presentationGateRef = useRef(presentationGate); const presentationGateRef = useRef(presentationGate);
presentationGateRef.current = presentationGate; presentationGateRef.current = presentationGate;
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0); const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0); const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
const recordedBlueprintUrl = sourceUrl const recordedBlueprintUrl = sourceUrl
? resolveRecordedBlueprintUrl( ? resolveRecordedBlueprintUrl(
@@ -752,9 +856,20 @@ export function RerunViewport({
: sourceUrl : sourceUrl
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin) ? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
: null; : null;
const recordedPointColorsUrl = sourceUrl const recordedPointColorsUrl = recordedBlueprintUrl
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) ? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
: null; : sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
const pointDisplay = useRecordedPointDisplay({
endpoint: recordedBlueprintUrl, settings: sceneSettings, revision: blueprintChannelRevision,
sourceGeneration: recordedArtifact?.sha256,
ready: status === 'ready' && pointDisplayVisibilityGate === `${recordedBlueprintUrl}:${blueprintChannelRevision}`,
onLoad: onPointColorLoadChange,
getOwner: () => {
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
return active?.channel.ready && identity ? {identity, send: bytes => active.channel.send_rrd(bytes)} : null;
},
});
const presentationStatus = rerunPresentationStatus( const presentationStatus = rerunPresentationStatus(
status, status,
presentationGate, presentationGate,
@@ -791,8 +906,7 @@ export function RerunViewport({
return; return;
} }
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource) const isRecordedSource = isRecordedRrdSource(normalizedSource);
|| LAB_RECORDED_REPLAY_PATH.test(normalizedSource);
let resolvedSource: string; let resolvedSource: string;
try { try {
if (isRecordedSource) { if (isRecordedSource) {
@@ -827,6 +941,9 @@ export function RerunViewport({
diagnosticLifecycle.verifyBuild(); diagnosticLifecycle.verifyBuild();
let disposed = false; let disposed = false;
const blueprintOwnerId = crypto.randomUUID().replaceAll("-", "");
blueprintSessionIdRef.current = blueprintOwnerId;
let releaseBlueprintSession = () => {};
let disposeViewer: (() => void) | undefined; let disposeViewer: (() => void) | undefined;
let recordedOpenWatchdog: { let recordedOpenWatchdog: {
arm: () => void; arm: () => void;
@@ -1073,6 +1190,11 @@ export function RerunViewport({
); );
} }
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle; activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
const restoreAfterPageCache = (event: PageTransitionEvent) => {
if (event.persisted) setRetryNonce((nonce) => nonce + 1);
};
window.addEventListener("pagehide", disposeActiveViewerLifecycle);
window.addEventListener("pageshow", restoreAfterPageCache);
host.replaceChildren(); host.replaceChildren();
appliedPointColorKeyRef.current = null; appliedPointColorKeyRef.current = null;
@@ -1080,15 +1202,22 @@ export function RerunViewport({
setRecordingBufferProgress(null); setRecordingBufferProgress(null);
onStatusChange?.("loading"); onStatusChange?.("loading");
void import("@rerun-io/web-viewer") const isolatedHost = isRecordedSource ? createIsolatedRerunHost(host) : null;
.then(async ({ WebViewer }) => { disposeViewer = () => isolatedHost?.dispose();
const nativeViewer = isolatedHost?.ready ?? import("@rerun-io/web-viewer")
.then(({ WebViewer }) => ({ viewer: new WebViewer(), mount: host }));
void nativeViewer
.then(async ({ viewer, mount }) => {
// React StrictMode intentionally disposes the first effect while the // React StrictMode intentionally disposes the first effect while the
// dynamic import is still pending. Never start that stale viewer (the // dynamic import is still pending. Never start that stale viewer (the
// vendor API treats a missing host as document.body). // vendor API treats a missing host as document.body).
if (disposed) return; if (disposed) {
viewer.stop();
const viewer = new WebViewer(); isolatedHost?.dispose();
return;
}
disposeViewer = createReentrantViewerDisposer(() => { disposeViewer = createReentrantViewerDisposer(() => {
releaseBlueprintSession();
clearRecordingTimers(); clearRecordingTimers();
clearPlaybackRangeTimer(); clearPlaybackRangeTimer();
playbackTimeUpdates.cancel(); playbackTimeUpdates.cancel();
@@ -1146,6 +1275,7 @@ export function RerunViewport({
// A partially initialized WASM handle can already be gone after a // A partially initialized WASM handle can already be gone after a
// startup failure. // startup failure.
} }
isolatedHost?.dispose();
host.replaceChildren(); host.replaceChildren();
}); });
if (isRecordedSource && recordedArtifact) { if (isRecordedSource && recordedArtifact) {
@@ -1182,6 +1312,13 @@ export function RerunViewport({
event.application_id === "nodedc_mission_core_recorded" && event.application_id === "nodedc_mission_core_recorded" &&
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id) /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id)
) { ) {
releaseBlueprintSession = keepRecordedBlueprintSession({
endpointUrl: recordedBlueprintUrl,
origin: window.location.origin,
applicationId: event.application_id,
recordingId: event.recording_id,
ownerId: blueprintOwnerId,
});
const current = recordedIdentityRef.current; const current = recordedIdentityRef.current;
if ( if (
current?.applicationId !== event.application_id || current?.applicationId !== event.application_id ||
@@ -1491,7 +1628,7 @@ export function RerunViewport({
}; };
await viewer.start( await viewer.start(
rerunViewerInitialSource(resolvedSource), rerunViewerInitialSource(resolvedSource),
host, mount,
viewerOptions, viewerOptions,
); );
if (disposed) { if (disposed) {
@@ -1509,7 +1646,21 @@ export function RerunViewport({
if (recordedBlueprintUrl) { if (recordedBlueprintUrl) {
const channel = viewer.open_channel("missioncore/recorded-blueprint"); const channel = viewer.open_channel("missioncore/recorded-blueprint");
blueprintChannel = { endpointUrl: recordedBlueprintUrl, channel }; blueprintChannel = {
endpointUrl: recordedBlueprintUrl,
cameraContract: null,
appliedFollowTrajectory: null,
pendingFollowCameraEye: null,
getCameraEye: () => "get_camera_eye" in viewer
? viewer.get_camera_eye()
: null,
getCurrentTimeNs: () => {
const currentIdentity = recordedIdentityRef.current;
if (!currentIdentity) return null;
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
},
channel,
};
blueprintChannelRef.current = blueprintChannel; blueprintChannelRef.current = blueprintChannel;
setBlueprintChannelRevision((revision) => revision + 1); setBlueprintChannelRevision((revision) => revision + 1);
} }
@@ -1569,6 +1720,8 @@ export function RerunViewport({
}); });
return () => { return () => {
window.removeEventListener("pagehide", disposeActiveViewerLifecycle);
window.removeEventListener("pageshow", restoreAfterPageCache);
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) { if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
activeViewerLifecycleRef.current = null; activeViewerLifecycleRef.current = null;
} }
@@ -1855,6 +2008,7 @@ export function RerunViewport({
useEffect(() => { useEffect(() => {
if (!recordedPointColorsUrl || !sceneSettings) return; if (!recordedPointColorsUrl || !sceneSettings) return;
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
const active = blueprintChannelRef.current; const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current; const identity = recordedIdentityRef.current;
if ( if (
@@ -1934,10 +2088,14 @@ export function RerunViewport({
sceneSettings?.colorMode, sceneSettings?.colorMode,
sceneSettings?.customColor, sceneSettings?.customColor,
sceneSettings?.palette, sceneSettings?.palette,
sceneSettings?.pointDecimationPercent,
]); ]);
useEffect(() => { useEffect(() => {
if (!recordedBlueprintUrl || !sceneSettings) return; if (!recordedBlueprintUrl || !sceneSettings) return;
// Initialize the portable following-eye blueprint after native admission
// and its initial seek, not while the base RRD is still arriving.
if (recordedPerceptionLayers.costmap !== undefined && status !== "ready") return;
const active = blueprintChannelRef.current; const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current; const identity = recordedIdentityRef.current;
if ( if (
@@ -1946,31 +2104,118 @@ export function RerunViewport({
active.endpointUrl !== recordedBlueprintUrl || active.endpointUrl !== recordedBlueprintUrl ||
!active.channel.ready !active.channel.ready
) return; ) return;
const cameraContract = recordedCameraJournalContract(
{
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
planView: recordedPlanView,
followTrajectory: recordedFollowTrajectory,
},
);
const cameraContractChanged = active.cameraContract !== cameraContract;
const abort = new AbortController(); const abort = new AbortController();
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, { const previousFollow = active.appliedFollowTrajectory ?? false;
origin: window.location.origin, const enablingFollow = recordedFollowTrajectory && !previousFollow;
blueprintSessionId: blueprintSessionIdRef.current, const disablingFollow = !recordedFollowTrajectory && previousFollow;
signal: abort.signal, const transitionEye = (enablingFollow || disablingFollow)
activeView: recordedView, ? active.getCameraEye?.()
viewResetGeneration: recordedViewResetGeneration, : null;
followTrajectory: recordedFollowTrajectory, if (enablingFollow && transitionEye) {
perceptionLayers: recordedPerceptionLayers, active.pendingFollowCameraEye = transitionEye;
semanticLayer: recordedSemanticLayer, } else if (disablingFollow) {
unifiedPerception: recordedUnifiedPerception, active.pendingFollowCameraEye = null;
planView: recordedPlanView, }
}).then((payload) => { const pendingFollowEye = recordedFollowTrajectory
if ( ? active.pendingFollowCameraEye ?? null
abort.signal.aborted || : null;
blueprintChannelRef.current !== active || const requestBlueprint = async (
recordedIdentityRef.current !== identity || cameraEye?: RecordedRerunCameraEye,
!active.channel.ready eyeRelativeToTracking = false,
) { reactivateUpdates = false,
) => {
const currentTimeNs = active.getCurrentTimeNs?.();
if (eyeRelativeToTracking && currentTimeNs == null) {
throw new Error("Recorded tracking cursor is unavailable");
}
return fetchRecordedBlueprintRrd(recordedBlueprintUrl,
{...sceneSettings, showPoints: sceneSettings.showPoints && !pointDisplay.hidePoints}, identity, {
displayPointBank: pointDisplay.bank,
origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
signal: abort.signal,
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: recordedFollowTrajectory,
perceptionLayers: recordedPerceptionLayers,
semanticLayer: recordedSemanticLayer,
unifiedPerception: recordedUnifiedPerception,
unifiedCameraShare: recordedUnifiedCameraShare,
planView: recordedPlanView,
cameraEye,
eyeRelativeToTracking,
// Only following-eye transitions need a pose at the cursor. Supplying
// time for display-only updates makes the API scan the entire archive
// for camera bounds, although neither the camera nor its pose changed.
currentTimeNs: eyeRelativeToTracking ? currentTimeNs : undefined,
reactivateUpdates,
});
};
const canApply = () => (
!abort.signal.aborted &&
blueprintChannelRef.current === active &&
recordedIdentityRef.current === identity &&
active.channel.ready
);
const applyPayload = (payload: Uint8Array) => {
if (!canApply()) return false;
active.channel.send_rrd(payload);
// Exclude inactive point generations before admitting their data into
// cached recordings whose embedded blueprint predates display controls.
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
return true;
};
void (async () => {
const firstEye = disablingFollow
? transitionEye ?? undefined
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
disablingFollow || recordedFollowTrajectory
);
const firstPayload = await requestBlueprint(
firstEye,
firstEyeIsTrackingRelative,
// Upstream copies incoming blueprints into an active store. Every
// settings update must activate it; merely appending rows is invisible.
// Activation copies the incoming blueprint store, not the operator's
// edited store. Preserve its actual native eye on display-only updates;
// omitting the eye resurrects the embedded startup camera.
true,
);
if (!applyPayload(firstPayload)) return;
active.cameraContract = cameraContract;
active.appliedFollowTrajectory = recordedFollowTrajectory;
if (!enablingFollow || !transitionEye) {
if (pendingFollowEye && firstEye === pendingFollowEye) {
active.pendingFollowCameraEye = null;
}
return; return;
} }
active.channel.send_rrd(payload);
}).catch(() => { // Rerun 0.36.3 intentionally fits a newly tracked transform to its
// bounding box. Apply the operator's relative eye on the next native
// frame, after tracking is established, to retain direction and zoom.
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
if (!canApply()) return;
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
if (!applyPayload(stabilizedPayload)) return;
active.pendingFollowCameraEye = null;
})().catch((error: unknown) => {
// The recording remains usable with its embedded default blueprint. // The recording remains usable with its embedded default blueprint.
// A later settings change retries through the same small channel. // A later settings change retries through the same small channel.
if (!abort.signal.aborted) console.warn("Recorded scene settings were not applied", error);
}); });
return () => abort.abort(); return () => abort.abort();
}, [ }, [
@@ -1981,11 +2226,15 @@ export function RerunViewport({
recordedFollowTrajectory, recordedFollowTrajectory,
recordedSemanticLayer, recordedSemanticLayer,
recordedUnifiedPerception, recordedUnifiedPerception,
recordedUnifiedCameraShare,
recordedPlanView, recordedPlanView,
recordedPerceptionLayers.enabled, recordedPerceptionLayers.enabled,
recordedPerceptionLayers.cameraImage,
recordedPerceptionLayers.detections2d, recordedPerceptionLayers.detections2d,
recordedPerceptionLayers.segmentation, recordedPerceptionLayers.segmentation,
recordedPerceptionLayers.cuboids3d, recordedPerceptionLayers.cuboids3d,
recordedPerceptionLayers.costmap,
status,
sceneSettings?.accumulationSeconds, sceneSettings?.accumulationSeconds,
sceneSettings?.customColor, sceneSettings?.customColor,
sceneSettings?.colorMode, sceneSettings?.colorMode,
@@ -1994,6 +2243,9 @@ export function RerunViewport({
sceneSettings?.showPoints, sceneSettings?.showPoints,
sceneSettings?.showTrajectory, sceneSettings?.showTrajectory,
sceneSettings?.showGrid, sceneSettings?.showGrid,
pointDisplay.ready,
pointDisplay.bank,
pointDisplay.hidePoints,
]); ]);
return ( return (
@@ -0,0 +1,33 @@
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveAICompositionReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import type { AICompositionRun } from "../../core/observatory/aiComposition";
function resolveComposition(
runId: string,
launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal },
) {
return resolveAICompositionReplay(runId, launch, options);
}
export function AICompositionReplay({ run }: { run: AICompositionRun }) {
const semantics = run.moduleIds.includes("ddrnet") || run.moduleIds.includes("eomt");
const detections = run.moduleIds.includes("rf-detr")
|| run.moduleIds.includes("object-distance");
const costmap = run.moduleIds.includes("tgs");
return (
<CanonicalResultRerunReplay
key={run.runId}
resultId={run.runId}
sessionId={run.sourceSessionId}
resolveReplay={resolveComposition}
semantics={semantics}
detections={detections}
costmap={costmap}
moduleIds={run.moduleIds}
viewerLayers={run.presentation.viewerLayers}
label={run.configurationLabel}
/>
);
}
@@ -88,6 +88,9 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls, mediaLayerControls,
spatialLayerControls, spatialLayerControls,
spatialLeadingControl, spatialLeadingControl,
spatialTrailingControl,
mediaModeControlsVisible = true,
paneToolbarsAlwaysVisible = false,
mediaMultiLayer = false, mediaMultiLayer = false,
mediaContent, mediaContent,
spatialContent, spatialContent,
@@ -116,10 +119,14 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls?: ReactNode; mediaLayerControls?: ReactNode;
spatialLayerControls?: ReactNode; spatialLayerControls?: ReactNode;
spatialLeadingControl?: ReactNode; spatialLeadingControl?: ReactNode;
spatialTrailingControl?: ReactNode;
mediaModeControlsVisible?: boolean;
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
paneToolbarsAlwaysVisible?: boolean;
mediaMultiLayer?: boolean; mediaMultiLayer?: boolean;
mediaContent?: ReactNode; mediaContent?: ReactNode;
spatialContent?: ReactNode; spatialContent?: ReactNode;
/** One upstream Rerun viewer owns both panes and the shared playback clock. */ /** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
unifiedContent?: ReactNode; unifiedContent?: ReactNode;
emptyMessage: string; emptyMessage: string;
deckOverlays?: ReactNode; deckOverlays?: ReactNode;
@@ -162,14 +169,14 @@ export function CanonicalRecordedLabReplay<
aria-label={mediaAriaLabel} aria-label={mediaAriaLabel}
hidden={mediaMode === "none"} hidden={mediaMode === "none"}
> >
{splitView ? ( {splitView || paneToolbarsAlwaysVisible ? (
<div <div
className="m4-replay-threat-visual__pane-toolbar" className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media" data-pane-toolbar="media"
data-multi-semantic={mediaMultiLayer ? "true" : undefined} data-multi-semantic={mediaMultiLayer ? "true" : undefined}
> >
{mediaLayerControls} {mediaLayerControls}
{mediaModeControls} {mediaModeControlsVisible ? mediaModeControls : null}
</div> </div>
) : null} ) : null}
{mediaContent} {mediaContent}
@@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay<
data-pane="spatial" data-pane="spatial"
aria-label={spatialAriaLabel} aria-label={spatialAriaLabel}
> >
{splitView ? ( {splitView || paneToolbarsAlwaysVisible ? (
<div <div
className="m4-replay-threat-visual__pane-toolbar" className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial" data-pane-toolbar="spatial"
@@ -190,6 +197,7 @@ export function CanonicalRecordedLabReplay<
<div className="m4-replay-threat-visual__spatial-toolbar-end"> <div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls} {spatialLayerControls}
{spatialModeControls} {spatialModeControls}
{spatialTrailingControl}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -216,7 +224,7 @@ export function CanonicalRecordedLabReplay<
expanded={expanded} expanded={expanded}
onModeChange={onMediaModeChange} onModeChange={onMediaModeChange}
onExpandedChange={onExpandedChange} onExpandedChange={onExpandedChange}
modeControlsVisible={!splitView} modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
actions={actions} actions={actions}
overlay={overlay} overlay={overlay}
transport={transport} transport={transport}
@@ -241,7 +249,7 @@ export function CanonicalRecordedLabReplay<
orientation={splitOrientation} orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0} minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0} minSecondarySize={splitView ? 24 : 0}
resizable={splitView && !unifiedContent} resizable={splitView}
separatorLabel="Изменить размер видео/камеры и 3D/плана" separatorLabel="Изменить размер видео/камеры и 3D/плана"
/> />
{mediaMode === "none" && spatialMode === "none" ? ( {mediaMode === "none" && spatialMode === "none" ? (
@@ -0,0 +1,525 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
Button,
Checker,
ControlRow,
Icon,
IconButton,
Inspector,
InspectorSelectField,
RangeControl,
ToastStack,
Window,
type ToastItem,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunViewportStatus,
} from "../RerunViewport";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../laboratory/CanonicalRecordedLabReplay";
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
import {
fetchLabViewProfile,
saveLabViewProfile,
} from "../../core/observatory/labViewProfile";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "../../sceneSettings";
type MediaMode = "camera";
type SpatialMode = "3d" | "plan";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const LAB_SCENE_SETTINGS_STORAGE_PREFIX = "missioncore.observatory.lab-scene-settings.v1:";
const LAB_VIEW_PROFILE_ERROR_TOAST = "observatory-lab-view-profile-error";
const COLOR_OPTIONS: readonly { value: PointColorMode; label: string }[] = [
{ value: "intensity", label: "Интенсивность" },
{ value: "height", label: "Высота Z" },
{ value: "distance", label: "Дистанция" },
{ value: "rgb", label: "RGB" },
{ value: "class", label: "Класс" },
];
const PALETTE_OPTIONS: readonly { value: PointPalette; label: string }[] = [
{ value: "turbo", label: "Turbo" },
{ value: "viridis", label: "Viridis" },
{ value: "plasma", label: "Plasma" },
{ value: "grayscale", label: "Серый" },
];
const LAB_SCENE_DEFAULTS: SceneSettings = {
...defaultSceneSettings,
pointSize: 3.8,
accumulationSeconds: 5,
};
export function normalizeLabSceneSettings(settings: SceneSettings): SceneSettings {
const pointSize = Number.isFinite(settings.pointSize) ? settings.pointSize : LAB_SCENE_DEFAULTS.pointSize;
const accumulationSeconds = Number.isFinite(settings.accumulationSeconds)
? settings.accumulationSeconds : LAB_SCENE_DEFAULTS.accumulationSeconds;
return {
...settings,
pointSize: Math.round(Math.max(0.1, pointSize) * 10) / 10,
accumulationSeconds: Math.round(Math.max(0, accumulationSeconds)),
};
}
function loadLabSceneSettings(resultId: string): SceneSettings {
if (typeof window === "undefined") return LAB_SCENE_DEFAULTS;
try {
const raw = window.localStorage.getItem(`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`);
if (raw === null) return LAB_SCENE_DEFAULTS;
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || Array.isArray(value)) return LAB_SCENE_DEFAULTS;
const row = value as Record<string, unknown>;
const pointSize = row.pointSize;
const accumulationSeconds = row.accumulationSeconds;
const colorMode = row.colorMode;
const palette = row.palette;
const showGrid = row.showGrid;
const showLabels = row.showLabels;
const showCameraFrustums = row.showCameraFrustums;
if (
typeof pointSize !== "number" || !Number.isFinite(pointSize) || pointSize < 0.1
|| typeof accumulationSeconds !== "number" || !Number.isFinite(accumulationSeconds)
|| accumulationSeconds < 0
|| !COLOR_OPTIONS.some((option) => option.value === colorMode)
|| !PALETTE_OPTIONS.some((option) => option.value === palette)
|| typeof showGrid !== "boolean"
|| typeof showLabels !== "boolean"
|| typeof showCameraFrustums !== "boolean"
) return LAB_SCENE_DEFAULTS;
return {
...LAB_SCENE_DEFAULTS,
pointSize,
accumulationSeconds,
colorMode: colorMode as PointColorMode,
palette: palette as PointPalette,
showGrid,
showLabels,
showCameraFrustums,
};
} catch {
return LAB_SCENE_DEFAULTS;
}
}
export function CanonicalResultRerunReplay({
resultId,
sessionId,
initialPlaybackStartSeconds,
resolveReplay,
semantics = false,
detections = false,
costmap = false,
moduleIds = [],
viewerLayers = [],
label = "Сохранённый результат",
}: {
resultId: string;
sessionId: string;
initialPlaybackStartSeconds?: number;
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
semantics?: boolean;
detections?: boolean;
costmap?: boolean;
portable?: boolean;
moduleIds?: readonly string[];
viewerLayers?: readonly AIViewerLayer[];
label?: string;
}) {
const {
mediaMode,
spatialMode,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "camera",
initialSpatialMode: "3d",
});
const layerIds = useMemo(
() => new Set(viewerLayers.map((layer) => layer.layerId)),
[viewerLayers],
);
const hasProjectedLayers = viewerLayers.length > 0;
const hasDDRNet = layerIds.has("camera.ddrnet")
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
const hasEoMT = layerIds.has("camera.eomt")
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
const legacyUnclassified = !hasProjectedLayers
&& moduleIds.length === 0
&& !semantics
&& !detections
&& !costmap;
const hasCameraPane = layerIds.has("camera.source")
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
const hasSourcePoints = layerIds.has("spatial.source-points")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasLocalSlam = layerIds.has("spatial.local-slam")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
const splitView = hasCameraPane && hasSpatialPane;
const [showCamera, setShowCamera] = useState(true);
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
const [showEoMT, setShowEoMT] = useState(hasEoMT);
const [showDetections, setShowDetections] = useState(hasDetections);
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
const [showTgs, setShowTgs] = useState(hasTgs);
const [followTarget, setFollowTarget] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0);
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const sceneDraftRef = useRef(sceneDraft);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
useEffect(() => {
const launchSha256 = launch?.replay.sha256 ?? null;
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
useEffect(() => {
if (!splitView) return;
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
return () => window.clearTimeout(timer);
}, [splitPrimarySize, splitView]);
useEffect(() => {
try {
window.localStorage.setItem(
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
JSON.stringify({
pointSize: sceneDraft.pointSize,
accumulationSeconds: sceneDraft.accumulationSeconds,
colorMode: sceneDraft.colorMode,
palette: sceneDraft.palette,
showGrid: sceneDraft.showGrid,
showLabels: sceneDraft.showLabels,
showCameraFrustums: sceneDraft.showCameraFrustums,
}),
);
} catch {
// The visual remains usable when the browser refuses local persistence.
}
}, [resultId, sceneDraft]);
useEffect(() => {
const controller = new AbortController();
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
if (!controller.signal.aborted && settings) {
sceneDraftRef.current = settings;
setSceneDraft(settings);
}
}).catch(() => {
// The per-browser copy remains a usable fallback while the local service recovers.
});
return () => controller.abort();
}, [resultId]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
})).then((value) => {
if (!controller.signal.aborted) setLaunch(value);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) {
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
}
});
return () => controller.abort();
}, [resultId, sessionId, resolveReplay]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady ? "ready" : "loading";
const sceneSettings = useMemo<SceneSettings>(() => ({
...sceneDraft,
projection: spatialMode === "plan" ? "2d" : "3d",
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
showTrajectory: spatialMode !== null && showLocalSlam,
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
const semanticVisible = showDDRNet || showEoMT;
const profile = launch ? laboratoryResultRerunProfile({
resultId,
sourceUrl: launch.replay.sourceUrl,
artifact: {
sourceUrl: launch.replay.sourceUrl,
viewerSourceUrl: launch.replay.viewerSourceUrl,
byteLength: launch.replay.byteLength,
sha256: launch.replay.sha256,
},
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
autoplayWhenReady: false,
presentationGate: "ready",
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
initialPlaybackStartSeconds: initialPlaybackStartSeconds
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
+ (hasTgs ? 0.000001 : 0)),
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: followTarget,
semanticLayer: showEoMT ? "city" : "vegetation",
unifiedPerception: splitView,
unifiedCameraShare: blueprintSplitPrimarySize / 100,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: true,
cameraImage: showCamera,
detections2d: hasDetections && showDetections,
segmentation: semantics && semanticVisible,
cuboids3d: false,
costmap: hasTgs && showTgs,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
}) : null;
const toggle = (caption: string, active: boolean, onClick: () => void) => (
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
aria-pressed={active} onClick={onClick}>
{caption}
</Button>
);
const openSlamSettings = useCallback(() => {
if (expanded) onExpandedChange(false);
setSettingsOpen(true);
}, [expanded, onExpandedChange]);
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
const next = { ...sceneDraftRef.current, ...patch };
sceneDraftRef.current = next;
setSceneDraft(next);
}, []);
const closeSlamSettings = useCallback(() => {
const next = normalizeLabSceneSettings(sceneDraftRef.current);
sceneDraftRef.current = next;
setSceneDraft(next);
setSettingsOpen(false);
void saveLabViewProfile(resultId, next).then((settings) => {
sceneDraftRef.current = settings;
setSceneDraft(settings);
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
}).catch(() => {
setToasts((current) => [
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
{
id: LAB_VIEW_PROFILE_ERROR_TOAST,
tone: "error",
title: "Настройки LAB не сохранились",
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
},
]);
});
}, [resultId]);
const mediaLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
{hasDDRNet || hasFallbackSemantics
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
{hasDetections
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
</div>
);
const spatialLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
{hasSourcePoints
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
{hasLocalSlam ? (
<div className="m4-replay-threat-visual__layer-with-settings">
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
<Icon name="settings" size={14} />
</IconButton>
</div>
) : null}
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
</div>
);
const followTargetControl = toggle(
"СЛЕДОВАТЬ",
followTarget,
() => setFollowTarget((value) => !value),
);
const resetSpatialView = (
<IconButton label="Сбросить положение видов"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
<Icon name="refresh" size={16} />
</IconButton>
);
const transport = presentationReady && playback && playbackController ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
active
sourceCount={3}
mode="recorded"
seekable
synchronization="shared-clock"
rangeNs={playback.rangeNs}
currentNs={playback.currentNs}
playing={playback.playing}
onSeek={playbackController.seek}
onPlayingChange={playbackController.setPlaying}
showJumpToEnd={false}
/>
) : undefined;
return (
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}>
<CanonicalRecordedLabReplay
label={label}
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitView ? "vertical" : splitOrientation}
mediaAriaLabel="Камера и рассчитанные слои"
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialTrailingControl={followTargetControl}
mediaModeControlsVisible={false}
paneToolbarsAlwaysVisible
mediaMultiLayer
actions={resetSpatialView}
unifiedContent={profile ? (
<div className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}>
<RerunViewport profile={profile} sceneSettings={sceneSettings}
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController} />
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
) : <div aria-hidden="true" />}
emptyMessage="Слои результата недоступны."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
placement="end" size="sm" onClose={closeSlamSettings}>
<Inspector defaultOpen={["points"]} sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: <>
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
exactValueBounds={{ min: 0.1 }}
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
options={[...COLOR_OPTIONS]}
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
options={[...PALETTE_OPTIONS]}
onChange={(palette) => patchSceneDraft({ palette })} />
</>,
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: <RangeControl label="Накопление" min={0} max={500} step={1}
exactValueBounds={{ min: 0 }}
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
},
{
id: "environment",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: <>
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
</>,
},
]} />
</Window>
<ToastStack items={toasts}
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
</div>
);
}
@@ -1,383 +1,12 @@
import { import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
useCallback, import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ActivityIndicator,
Button,
Icon,
SegmentedControl,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunViewportStatus,
} from "../RerunViewport";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../laboratory/CanonicalRecordedLabReplay";
import {
resolveCanonicalLabReplay,
type CanonicalLabReplayDescriptor,
} from "../../core/laboratory/canonicalLabReplay";
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow"; import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import { defaultSceneSettings } from "../../sceneSettings";
type MediaMode = "video" | "camera"; export function CanonicalVegetationRerunReplay({ resultId, review }: {
type SpatialMode = "3d" | "plan";
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
type SemanticLayer = "city" | "vegetation";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
export function CanonicalVegetationRerunReplay({
resultId,
review,
}: {
resultId: string; resultId: string;
review: VegetationFullRouteReview; review: VegetationFullRouteReview;
}) { }) {
const { return <CanonicalResultRerunReplay resultId={resultId} sessionId={review.sessionId}
mediaMode, initialPlaybackStartSeconds={review.timelineStartSeconds} semantics
spatialMode, resolveReplay={resolveCanonicalLabReplay} label="RAVNOVES004TREE · канонический повтор Rerun" />;
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "video",
initialSpatialMode: "3d",
});
const splitView = mediaMode !== null && spatialMode !== null;
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
const [showSemantics, setShowSemantics] = useState(true);
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] =
useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const viewerFrameRef = useRef<HTMLDivElement>(null);
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
const stopNativeSplitTracking = useCallback(() => {
nativeSplitTrackingCleanupRef.current?.();
nativeSplitTrackingCleanupRef.current = null;
}, []);
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (
!splitView
|| event.button !== 0
|| !(event.target instanceof HTMLCanvasElement)
) return;
const frame = viewerFrameRef.current;
if (!frame) return;
const bounds = frame.getBoundingClientRect();
if (bounds.width <= 0) return;
const dividerX = bounds.left
+ bounds.width * nativeSplitPercentRef.current / 100;
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
stopNativeSplitTracking();
const pointerId = event.pointerId;
const update = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
const currentBounds = frame.getBoundingClientRect();
if (currentBounds.width <= 0) return;
const next = Math.min(90, Math.max(
10,
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
));
nativeSplitPercentRef.current = next;
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
};
const stop = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
stopNativeSplitTracking();
};
window.addEventListener("pointermove", update, true);
window.addEventListener("pointerup", stop, true);
window.addEventListener("pointercancel", stop, true);
nativeSplitTrackingCleanupRef.current = () => {
window.removeEventListener("pointermove", update, true);
window.removeEventListener("pointerup", stop, true);
window.removeEventListener("pointercancel", stop, true);
};
}, [splitView, stopNativeSplitTracking]);
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
useEffect(() => {
if (!splitView) stopNativeSplitTracking();
const launchSha256 = launch?.replay.sha256 ?? null;
if (
trackedLaunchSha256Ref.current !== launchSha256
|| (splitView && !previousSplitViewRef.current)
) {
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
const cameraPanePercent = mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100;
viewerFrameRef.current?.style.setProperty(
"--canonical-rerun-camera-pane",
`${cameraPanePercent}%`,
);
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(review.sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveCanonicalLabReplay(resultId, value, {
signal: controller.signal,
}),
})).then((value) => {
if (!controller.signal.aborted) setLaunch(value);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) {
setLaunchError(
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
);
}
});
return () => controller.abort();
}, [resultId, review.sessionId]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady
? "ready"
: "loading";
const sceneSettings = useMemo(() => ({
...defaultSceneSettings,
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
showPoints: spatialMode !== null,
showTrajectory: spatialMode !== null,
showGrid: spatialMode !== null,
pointSize: 3.8,
}), [spatialLayer, spatialMode]);
const profile = launch ? recordedSessionRerunProfile({
sourceUrl: launch.replay.sourceUrl,
artifact: {
sourceUrl: launch.replay.sourceUrl,
viewerSourceUrl: launch.replay.viewerSourceUrl,
byteLength: launch.replay.byteLength,
sha256: launch.replay.sha256,
},
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
autoplayWhenReady: false,
presentationGate: "ready",
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
initialPlaybackStartSeconds: review.timelineStartSeconds,
view: mediaMode !== null ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: true,
semanticLayer,
unifiedPerception: splitView,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: mediaMode !== null,
detections2d: mediaMode === "video",
segmentation: mediaMode === "video" && showSemantics,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: mediaMode !== null,
}) : null;
const mediaLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Слои камеры и видео"
>
<Button
size="dense"
shape="pill"
variant={showSemantics ? "primary" : "secondary"}
aria-pressed={showSemantics}
onClick={() => setShowSemantics((visible) => !visible)}
>
СЕМАНТИКА
</Button>
<SegmentedControl
value={semanticLayer}
items={[
{ value: "city", label: "ГОРОД · EoMT" },
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
]}
label="Источник семантики"
size="dense"
onChange={(value) => {
setSemanticLayer(value);
setShowSemantics(true);
}}
/>
</div>
);
const spatialLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Пространственные слои RAV004"
>
<SegmentedControl
value={spatialLayer}
items={[
{ value: "source", label: "ИСХ. ТОЧКИ" },
{ value: "local", label: "ЛОК. SLAM" },
{ value: "tgs", label: "TGS", disabled: true },
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
]}
label="Пространственные слои"
size="dense"
onChange={setSpatialLayer}
/>
</div>
);
const resetSpatialView = (
<Button
size="dense"
variant="ghost"
icon={<Icon name="refresh" size={14} />}
aria-label="Сбросить положение 3D камеры"
title="Сбросить положение 3D камеры"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
>
</Button>
);
const transport = presentationReady && playback && playbackController ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
active
sourceCount={3}
mode="recorded"
seekable
synchronization="shared-clock"
rangeNs={playback.rangeNs}
currentNs={playback.currentNs}
playing={playback.playing}
onSeek={playbackController.seek}
onPlayingChange={playbackController.setPlaying}
showJumpToEnd={false}
/>
) : undefined;
return (
<div
className="canonical-vegetation-rerun-replay"
data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}
>
<CanonicalRecordedLabReplay
label="RAVNOVES004TREE · канонический повтор Rerun"
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "ВИДЕО" },
{ value: "camera", label: "КАМЕРА" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "ПЛАН" },
]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer
unifiedContent={profile ? (
<div
ref={viewerFrameRef}
className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}
style={{
"--canonical-rerun-camera-pane": `${
mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100
}%`,
} as CSSProperties}
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
>
<RerunViewport
profile={profile}
sceneSettings={sceneSettings}
onStatusChange={setViewerStatus}
onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController}
/>
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">
{launchError}
</div>
) : (
<div aria-hidden="true" />
)}
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
</div>
);
} }
@@ -53,7 +53,7 @@ export const LABORATORY_REPORT_TEMPLATE_VERSION =
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = { const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
deterministic: "Детерминированный", deterministic: "Детерминированный",
"ai-inference": "AI inference", "ai-inference": "AI Inference",
hybrid: "Гибридный", hybrid: "Гибридный",
}; };
@@ -0,0 +1,51 @@
import { GlassSurface } from "@nodedc/ui-react";
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
}
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
}
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
}
export function PortableResultReplay({
review,
label,
}: {
review: ObservatoryPortableResultReview;
label?: string;
}) {
const schema = review.resultDocument.schema_version;
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
const module = review.resultDocument.module;
const moduleId = module && typeof module === "object" && "module_id" in module
? module.module_id : undefined;
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|| (schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "ddrnet" || moduleId === "eomt"));
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "rf-detr" || moduleId === "object-distance");
if (!tgs && !semantics && !objects) return (
<GlassSurface className="observatory-replay-state" padding="lg">
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
</GlassSurface>
);
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
detections={objects}
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
label={label}
resolveReplay={tgs ? resolvePortableTgsReplay
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
}
@@ -0,0 +1,59 @@
import type {MapVisualSettings} from '../../core/map/mapView';
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
return {
atmosphere_enabled: settings.atmosphereEnabled,
atmosphere_hue: settings.atmosphereHue,
atmosphere_saturation:
settings.atmosphereSaturation,
atmosphere_brightness:
settings.atmosphereBrightness,
fog_enabled: settings.fogEnabled,
fog_density: settings.fogDensity,
sun_enabled: settings.sunEnabled,
sun_hour: settings.sunHour,
sun_intensity: settings.sunIntensity,
shadows_enabled: settings.shadowsEnabled,
monochrome_enabled: settings.monochromeEnabled,
monochrome_color: settings.monochromeColor,
imagery_brightness: settings.imageryBrightness,
imagery_contrast: settings.imageryContrast,
imagery_saturation: settings.imagerySaturation,
imagery_gamma: settings.imageryGamma,
imagery_hue: settings.imageryHue,
imagery_alpha: settings.imageryAlpha,
globe_color: settings.globeColor,
background_color: settings.backgroundColor,
terrain_exaggeration:
settings.terrainExaggeration,
buildings_color: settings.buildingsColor,
buildings_opacity: settings.buildingsOpacity,
buildings_maximum_screen_space_error:
settings.buildingsMaximumScreenSpaceError,
grid_lod_enabled: settings.gridLodEnabled,
grid_height_meters: settings.gridHeightMeters,
grid_lod_1_max_height_km:
settings.gridLod1MaxHeightKm,
grid_lod_1_step_km:
settings.gridLod1StepKm,
grid_lod_2_max_height_km:
settings.gridLod2MaxHeightKm,
grid_lod_2_step_km:
settings.gridLod2StepKm,
grid_lod_3_step_km:
settings.gridLod3StepKm,
grid_radius_km: settings.gridRadiusKm,
grid_line_width: settings.gridLineWidth,
grid_color: settings.gridColor,
grid_opacity: settings.gridOpacity,
grid_dots_enabled:
settings.gridDotsEnabled,
grid_dots_size: settings.gridDotsSize,
grid_dots_color: settings.gridDotsColor,
grid_dots_opacity:
settings.gridDotsOpacity,
camera_animation_enabled:
settings.cameraAnimationEnabled,
};
}
@@ -0,0 +1,21 @@
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
import type { RouteCheck } from "../../core/missions/planner";
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
}) {
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
<LoadingRegion loading={busy} label="Проверка исходных данных">
{error && <p role="alert">{error}</p>}
{report && <><dl>
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
{report.warnings.map(w => <p key={w}>{w}</p>)}
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
</LoadingRegion>
</div></Window>;
}
@@ -0,0 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { RangeControl } from "@nodedc/ui-react";
import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner";
/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */
export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) {
const [cursor, setCursor] = useState(0);
useEffect(() => setCursor(0), [poses]);
const projection = useMemo(() => {
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); }
const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin));
const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale];
const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" ");
return { project, reference: line(source.poses), selected: line(poses), scale };
}, [source, poses]);
const point = poses[Math.min(cursor, poses.length - 1)];
if (!point) return <div className="session-overview__empty">Выберите участок траектории.</div>;
const [x, y] = projection.project(point);
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
</div>;
}
@@ -0,0 +1,12 @@
import type {ReactNode} from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { useSessionOverview } from "../../core/observation/useSessionOverview";
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
const { data, error, retry } = useSessionOverview(sessionId);
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
const failure = error || (data?.state === "error" ? data.message : null);
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
}
@@ -0,0 +1,23 @@
import type {ReactNode} from 'react';
import {Button, StatusBadge} from '@nodedc/ui-react';
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Composition-level handoff; never sends a device or acquisition command. */
export function PlanningCaptureGuard({enabled, onResume, children}: {
enabled: boolean;
onResume: () => void;
children: ReactNode;
}) {
const planning = usePlanningTest();
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
return <div className="mission-planner__check">
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
<p>{planning.test!.draft.name}</p>
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
{planning.error && <p role="alert">{planning.error}</p>}
<div className="mission-planner__actions">
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
</div>
</div>;
}
@@ -0,0 +1,32 @@
import {useEffect,useState,type ReactNode} from 'react';
import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react';
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Wrap the existing plugin connection flow, retaining its acquisition authority. */
export function PlanningConnectionWindow({children}:{children:ReactNode}){
const p=usePlanningTest(); const [open,setOpen]=useState(true);
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
useEffect(()=>{
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
if(models.length===1)void selectModel(models[0].model.id);
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
if(!p.test)return <>{children}</>;
const t=p.test, terminal=planningTestTerminal(t);
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
return <div className="mission-planner__check">
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
<div className="mission-planner__check">{details}
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
: terminal?<p>{t.message}</p>:children}
</div>
</Window>
</div>;
}
@@ -0,0 +1,105 @@
import {useEffect,useRef,useState} from 'react';
import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react';
import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost';
import {plannerBase} from '../../core/missions/planner';
import {startPlanningSceneStream} from '../../core/missions/planningSceneStream';
import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry';
function nextAnimationFrame(limitMs=500):Promise<number|null>{
return new Promise(resolve=>{
let frame:number|undefined,settled=false;
const finish=(value:number|null)=>{
if(settled)return;
settled=true;
if(frame!==undefined&&value===null)cancelAnimationFrame(frame);
clearTimeout(timeout);resolve(value);
};
const timeout=setTimeout(()=>finish(null),limitMs);
frame=requestAnimationFrame(()=>finish(performance.now()));
});
}
/** One persistent viewer/channel. Updates replace entities without resetting the camera. */
type HeightBounds={min:number;max:number};
const CLIP_CEILING_M=80;
export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record<string,string|number|boolean>;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){
const optionsRef=useRef(options);optionsRef.current=options;
const stateRef=useRef({active,revision});stateRef.current={active,revision};
const ceilingRef=useRef<number|null>(null);
const boundsRef=useRef<{min:number;max:number}|null>(null);
const host=useRef<HTMLDivElement>(null);
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
const [ceiling,setCeiling]=useState<number|null>(null);
useEffect(()=>{
ceilingRef.current=null;
const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null;
boundsRef.current=next;
setBounds(next);setCeiling(null);
},[runId,heightBounds?.min,heightBounds?.max]);
useEffect(()=>{
if(!host.current)return;
let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined;
const reporter=createPlanningPresentationReporter({
url:`${plannerBase}/live-tests/${runId}/presentation-observations`,
});
const runtime=createIsolatedRerunHost(host.current);setState('loading');
const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000);
void runtime.ready.then(async({viewer,mount})=>{
if(disposed)return;
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false});
if(disposed)return;
const channel=viewer.open_channel('planning-'+runId);
viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}});
stopStream=startPlanningSceneStream({
url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`,
snapshot:()=>({...stateRef.current,options:{...optionsRef.current,
...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}),
apply:async(bytes,delivery)=>{
if(disposed)return;
if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){
const next={min:delivery.heightMinM,max:delivery.heightMaxM};
const previous=boundsRef.current;
if(!previous||previous.min!==next.min||previous.max!==next.max){
boundsRef.current=next;setBounds(next);
if(ceilingRef.current!==null&&(ceilingRef.current<next.min||ceilingRef.current>next.max)){
ceilingRef.current=null;setCeiling(null);
}
}
}
sceneAvailable=true;
const admissionStarted=performance.now();
channel.send_rrd(bytes);
const admissionEnded=performance.now();
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');}
// Rerun exposes no canvas-paint receipt. Two bounded browser frame
// opportunities are retained as an explicit proxy, never as a GPU claim.
const first=await nextAnimationFrame();
const second=first===null?null:await nextAnimationFrame();
if(!disposed)reporter.record(delivery,{
rerunAdmissionMs:admissionEnded-admissionStarted,
firstAnimationFrameMs:first===null?null:first-admissionEnded,
secondAnimationFrameMs:second===null?null:second-admissionEnded,
});
},
error:()=>{sceneAvailable=false;if(!disposed)setState('error');},
});
}).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}});
return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();};
},[runId,retry]);
const sourceBounds=bounds??heightBounds;
const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null;
return <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
formatValue={value=>`${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')}
onChange={value=>{
const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value;
ceilingRef.current=next;setCeiling(next);
}}/>
</div>}
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
</LoadingRegion>;
}
@@ -0,0 +1,25 @@
import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react';
import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects';
export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) {
const r=p.result;
return <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
</div>},
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
</div>},
]}/>;
}
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react';
import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects';
export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: {
items: PlanningProject[];
value: string;
disabled?: boolean;
onChange: (key: string) => void;
onRemove: (project: PlanningProject) => Promise<void>;
}) {
const [target, setTarget] = useState<PlanningProject | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
return <>
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
value: item.key, label: item.name,
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
action: {
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
icon: <Icon name="trash" />,
tone: 'danger' as const,
disabled: !planningProjectDeletable(item),
onAction: () => { setError(null); setTarget(item); },
},
}))]}
onChange={key => { if (key) onChange(key); }} />
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
description={target ? <>
<strong>{target.name}</strong>
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
</> : null}
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
onClose={() => { if (!busy) setTarget(null); }}
onConfirm={async () => {
if (!target || busy) return;
setBusy(true); setError(null);
try { await onRemove(target); setTarget(null); }
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
finally { setBusy(false); }
}} />
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
onDismiss={() => setError(null)} />
</>;
}
@@ -0,0 +1,30 @@
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
export function PlanningProjectSettings({p,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
}) {
const disabled=p.busy||starting;
const length=routeLength(p.poses);
const routeReady=canStartPlanningRoute(length,'scanner');
return <><Inspector variant="panel" defaultOpen={['project','zone']} sections={[
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
</div>},
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
{p.source&&<>
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
</>}
</div>},
]}/>
<div className="inspector-control-stack">
{p.source&&!routeReady&&<small>В записи недостаточно перемещения для привязки. Выберите другой эталон.</small>}
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!routeReady} onClick={onStart}><Icon name="play"/>Начать новый проход</Button>
{p.error&&<p role="alert">{p.error}</p>}
</div>
</>;
}
@@ -0,0 +1,19 @@
import {useState,type ReactNode,type RefObject} from 'react';
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
}){
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
const [maximized,setMaximized]=useState(false);
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
title={title} maximized={maximized} onMaximizedChange={setMaximized}
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
}}>
<div className="mission-planner__check">{children}</div>
</WorkspaceWindow>;
}
@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
useEffect(() => {
if (!host.current) return;
let disposed = false; setState("loading");
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
</LoadingRegion>;
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from "react";
import { overviewChartPoints } from "../../core/observation/sessionOverview";
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
const svg = useRef<SVGSVGElement>(null);
const [size, setSize] = useState({ width: 900, height: 160 });
useEffect(() => {
if (!svg.current) return;
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
}
});
observer.observe(svg.current);
return () => observer.disconnect();
}, [chart.length > 0]);
const width = Math.max(1, size.width - 106);
const height = Math.max(1, size.height - 38);
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
return <div className="session-overview__chart">
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
<g transform="translate(64,10)">
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
</g>)}
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
</g>
</svg>
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
</div>;
}
@@ -0,0 +1,96 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [retry, setRetry] = useState(0);
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
const [ceiling, setCeiling] = useState<number | null>(null);
const [mode, setMode] = useState<OverviewViewMode>("3d");
const [viewError, setViewError] = useState<string | null>(null);
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
const [updating, setUpdating] = useState(false);
const comparison = metadata?.comparison;
const appliedMode = useRef<OverviewViewMode | null>(null);
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
useEffect(() => {
if (!host.current) return;
let disposed = false;
const abort = new AbortController();
appliedMode.current = null;
setState("loading");
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
setRepresentation("original"); setAppliedRepresentation("original"); setUpdating(false);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(data => {
if (disposed) return;
setRepresentation(data.default_representation ?? "original"); setMetadata(data);
}).catch(() => { if (!disposed) { setState("error"); setViewError("Версия облака недоступна."); } });
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true,
enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
useEffect(() => {
if (state !== "ready" || !metadata || !controller.current) return;
const abort = new AbortController();
setUpdating(true);
const timer = setTimeout(() => {
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal,
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
if (abort.signal.aborted || !controller.current) return;
controller.current.channel.send_rrd(result.bytes);
appliedMode.current = mode;
setVisiblePoints(result.visiblePoints); setViewError(null);
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
}, 180);
return () => { abort.abort(); clearTimeout(timer); };
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
const low = comparison?.height_min_m ?? metadata?.height_min_m;
const high = comparison?.height_max_m ?? metadata?.height_max_m;
return <>
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
{toolbar}
</div>
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
</div>}
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</LoadingRegion>
<span className="session-overview__note" role="status" aria-busy={updating}>
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
</span>
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</>;
}
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
FieldFrame,
Select,
StatusBadge,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
aiModuleIdForSetup,
fetchAIModuleCatalog,
fetchAICompositionRuns,
fetchAICompositionJobs,
saveAIComposition,
type AICompositionReceipt,
type AICompositionRun,
type AIGroupId,
type AIModuleCatalog,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
];
export function AIConfigurationWindow({
open, sourceSessionId, sourceLabel, onClose, onCalculated,
}: {
readonly open: boolean;
readonly sourceSessionId: string;
readonly sourceLabel: string;
readonly onClose: () => void;
readonly onCalculated: (receipt: AICompositionReceipt) => void;
}) {
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const request = new AbortController();
setState("loading");
setMessage(null);
void Promise.all([
fetchAIModuleCatalog(request.signal),
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
]).then(([value, jobs, runs]) => {
if (request.signal.aborted) return;
setCatalog(value);
setExistingJobs(jobs);
setExistingRuns(runs);
setSelected({});
setState("ready");
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
setState("error");
});
return () => request.abort();
}, [open, sourceSessionId]);
const existingByModule = useMemo(() => {
const result = new Map<string, ObservatoryRecordedJob>();
for (const job of existingJobs) {
if (job.state === "failed" || job.publication.state === "failed") continue;
const moduleId = aiModuleIdForSetup(job.setupId);
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
}
return result;
}, [existingJobs]);
const selections = useMemo(() => groups.flatMap(({ id }) => {
const moduleId = selected[id];
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
(item) => item.moduleId === moduleId,
);
return module ? [{ group: id, module }] : [];
}), [catalog, selected]);
const selectedModuleIds = useMemo(
() => selections.map(({ module }) => module.moduleId).sort(),
[selections],
);
const matchingRun = useMemo(() => existingRuns.find((run) => (
run.state !== "failed"
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
)), [existingRuns, selectedModuleIds]);
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
const configurationIsRunning = matchingRun?.state === "running";
function selectModule(group: AIGroupId, moduleId: string): void {
if (!catalog) return;
setSelected((current) => {
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
const modules = catalog.groups.flatMap((item) => item.modules);
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
if (moduleId !== "none") {
const module = modules.find((item) => item.moduleId === moduleId);
for (const requirement of module?.requires ?? []) {
if (requirement.startsWith("source.")) continue;
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
continue;
}
const providers = modules.filter((item) => item.provides.includes(requirement));
if (providers.length === 1) {
const providerGroup = catalog.groups.find((item) => item.modules.some(
(candidate) => candidate.moduleId === providers[0]?.moduleId,
))?.group;
if (providerGroup) {
next[providerGroup] = providers[0]?.moduleId;
selectedIds.add(providers[0]?.moduleId ?? "");
}
}
}
} else {
let changed = true;
while (changed) {
changed = false;
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
.flatMap((item) => item.provides));
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
(module) => module.moduleId === item.moduleId,
))?.group;
if (dependentGroup) {
next[dependentGroup] = undefined;
changed = true;
}
}
}
}
}
return next;
});
setMessage(null);
if (state === "error") setState("ready");
}
async function calculate(): Promise<void> {
if (selections.length === 0 || state === "saving") return;
if (configurationAlreadyExists) {
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
return;
}
setState("saving");
setMessage(null);
try {
const receipt = await saveAIComposition(sourceSessionId, selections);
onCalculated(receipt);
onClose();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
setState("error");
}
}
return <Window
open={open}
className="observatory-ai-config-window"
title="Сконфигурировать AI-слой"
subtitle={sourceLabel}
size="lg"
closeOnBackdrop={state !== "saving"}
closeOnEscape={state !== "saving"}
onClose={onClose}
footer={<WindowFooterActions>
{configurationAlreadyExists ? (
<span className="observatory-ai-config__duplicate" role="status">
{configurationIsRunning
? "Текущая конфигурация уже рассчитывается."
: "Текущая конфигурация уже рассчитана."}
</span>
) : null}
<Button
variant="primary"
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
aria-busy={state === "saving"}
onClick={() => void calculate()}
>
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
</Button>
</WindowFooterActions>}
>
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
<p className="observatory-ai-config__lead">
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
</p>
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
{catalog ? groups.map(({ id, label, description }) => {
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
const selectedExisting = selectedModule
? existingByModule.get(selectedModule.moduleId)
: undefined;
const allCalculated = modules.length > 0 && modules.every(
(module) => existingByModule.has(module.moduleId),
);
return <section className="observatory-ai-module-group" key={id}>
<header>
<div>
<strong>{label}</strong>
<span>{description}</span>
</div>
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
{selectedExisting
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
: selectedModule?.label ?? (allCalculated
? "Результат доступен"
: modules.length ? "Не используется" : "Не установлен")}
</StatusBadge>
</header>
<div className="observatory-ai-module-group__body">
<FieldFrame
label={`Модуль: ${label}`}
description={modules.length
? allCalculated
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
: id === "segmentation"
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
: id === "range"
? "Дистанция использует рамки детектора и синхронное облако точек."
: id === "geometry"
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
: "Связи с другими блоками определяются входами выбранного модуля."
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
>
<Select
label={`Выбрать модуль: ${label}`}
value={selected[id] ?? "none"}
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
value: module.moduleId,
label: module.label,
description: existingByModule.has(module.moduleId)
? "Готовый результат будет использован без повторного расчёта"
: module.dockerName,
}))]}
disabled={modules.length === 0 || state === "saving"}
onChange={(value) => selectModule(id, value)}
/>
</FieldFrame>
</div>
</section>;
}) : null}
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
</div>
</Window>;
}
@@ -0,0 +1,10 @@
import { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { createRecordedRerunOwner } from "./recordedRerunOwner";
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
const mount = document.getElementById("viewer");
if (!mount) throw new Error("Recorded viewer mount is missing");
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
@@ -0,0 +1,77 @@
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
export function createIsolatedRerunHost(host: HTMLElement) {
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
frame.title = "Синхронизированная запись";
frame.className = "rerun-viewport__runtime";
frame.src = "/rerun-runtime.html";
let disposed = false;
let releaseFacade: (() => void) | null = null;
let releaseInput: (() => void) | null = null;
let frameWindow: RerunFrameWindow | null = null;
let rejectReady: ((reason: Error) => void) | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
frame!.onload = null;
frame!.onerror = null;
releaseInput?.();
releaseInput = null;
try {
releaseFacade?.();
} catch {
// Realm teardown is required even if a partial native start failed.
} finally {
releaseFacade = null;
frameWindow = null;
frame!.remove();
frame = null;
rejectReady?.(new Error("Recorded viewer was disposed"));
rejectReady = null;
}
};
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
rejectReady = reject;
frame!.onload = () => {
if (disposed) return;
try {
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
const api = frameWindow?.missionCoreRerun;
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
const owner = createRecordedRerunFacade((request, bytes) => {
const current = frameWindow?.missionCoreRerun;
if (!current) throw new Error("Recorded viewer was disposed");
return current.invoke(request, bytes);
});
releaseFacade = owner.dispose;
api.connect(owner.notify);
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
clearTimeout(timer);
rejectReady = null;
frame!.onload = null;
frame!.onerror = null;
resolve({ viewer: owner.facade, mount: host });
} catch (error) {
reject(new Error(String(error)));
dispose();
}
};
frame!.onerror = () => {
reject(new Error("Recorded viewer host failed to load"));
dispose();
};
timer = setTimeout(() => {
reject(new Error("Recorded viewer host timed out"));
dispose();
}, 30_000);
});
host.append(frame);
return { ready, dispose };
}
@@ -0,0 +1,17 @@
/** Relay Escape from the isolated viewer realm to the owning application. */
export function relayIsolatedRerunInput(
host: HTMLElement,
frameWindow: Window | null,
_frame: HTMLIFrameElement | null,
) {
const parent = host.ownerDocument.defaultView;
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
};
frameWindow?.addEventListener("keydown", escape);
return () => {
frameWindow?.removeEventListener("keydown", escape);
frameWindow = null;
};
}
@@ -0,0 +1,28 @@
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
export type RecordedRerunCameraEye = {
readonly position: readonly [number, number, number];
readonly lookTarget: readonly [number, number, number];
readonly eyeUp: readonly [number, number, number];
};
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
};
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
};
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
if (value == null) return null; // No 3D frame rendered yet.
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
const eye = value as Record<string, unknown>;
const vector = (key: string): [number, number, number] => {
const item = eye[key];
if (!Array.isArray(item) || item.length !== 3 ||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
throw new Error("Invalid native Rerun camera vector");
}
return [item[0], item[1], item[2]];
};
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
}
@@ -0,0 +1,93 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunInvoke } from "./recordedRerunProtocol";
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
export type RecordedRerunViewer = Pick<WebViewer,
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
"get_playing" | "get_time_range" | "set_active_timeline" |
"set_current_time" | "set_playing"
> & {
open_channel: (name?: string) => Channel;
get_camera_eye: () => RecordedRerunCameraEye | null;
};
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
let nextId = 0;
const callbacks = new Map<number, (...args: unknown[]) => void>();
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
if (!invoke) throw new Error("Recorded viewer was disposed");
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
if (response.error) throw new Error(response.error);
return response.result;
};
const dispose = () => {
callbacks.clear();
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
starts.clear();
try { if (invoke) command("stop"); } finally { invoke = null; }
};
const notify = (message: string) => {
if (!invoke) return;
const event = JSON.parse(message);
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
else {
const pending = starts.get(event.id);
starts.delete(event.id);
if (event.type === "started") pending?.resolve();
else pending?.reject(new Error(event.message));
}
};
const facade: RecordedRerunViewer = {
get ready() { return invoke ? command("ready") : false; },
start(source, _parent, options) {
return new Promise<void>((resolve, reject) => {
const id = ++nextId;
starts.set(id, { resolve, reject });
try { command("start", [source, options], id); }
catch (error) { starts.delete(id); reject(error); }
});
},
stop: dispose,
on: ((event: string, callback: (...args: unknown[]) => void) => {
const id = ++nextId;
callbacks.set(id, callback);
try { command("on", [event], id); }
catch (error) { callbacks.delete(id); throw error; }
return () => {
if (callbacks.delete(id) && invoke) command("off", [], id);
};
}) as WebViewer["on"],
open_channel(name) {
const id = ++nextId;
command("channel-open", name === undefined ? [] : [name], id);
let closed = false;
return {
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
close() {
if (closed) return;
closed = true;
if (invoke) command("channel-close", [], id);
},
};
},
open: (...args) => command("open", args),
close: (...args) => { if (invoke) command("close", args); },
override_panel_state: (...args) => command("override_panel_state", args),
get_active_recording_id: () => command("get_active_recording_id"),
get_active_timeline: (...args) => command("get_active_timeline", args),
get_current_time: (...args) => command("get_current_time", args),
get_playing: (...args) => command("get_playing", args),
get_time_range: (...args) => command("get_time_range", args),
set_active_timeline: (...args) => command("set_active_timeline", args),
set_current_time: (...args) => command("set_current_time", args),
set_playing: (...args) => command("set_playing", args),
get_camera_eye: () => command("get-camera-eye"),
};
return { facade, dispose, notify };
}
@@ -0,0 +1,104 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
let native: WebViewer | null = null;
let notify: RerunNotify | null = null;
const subscriptions = new Map<number, () => void>();
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
const send = (message: object) => notify?.(JSON.stringify(message));
const stop = () => {
notify = null;
for (const unsubscribe of subscriptions.values()) {
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
}
subscriptions.clear();
for (const channel of channels.values()) {
try { channel.close(); } catch { /* Other channels must still close. */ }
}
channels.clear();
const current = native;
native = null;
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
};
const required = () => {
if (!native) throw new Error("Recorded viewer was disposed");
return native;
};
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
const starting = required();
try {
await starting.start(args[0], mount, args[1]);
if (native === starting) send({ type: "started", id });
} catch (error) {
if (native === starting) send({ type: "start-failed", id, message: String(error) });
} finally {
if (native !== starting) {
try { starting.stop(); } catch { /* Late completion after disposal. */ }
}
}
};
return {
version: 2,
connect(callback) {
if (native) throw new Error("Recorded viewer already has an owner");
native = create();
notify = callback;
},
invoke(request, bytes) {
try {
const { method, args = [], id } = JSON.parse(request);
let result: unknown;
switch (method) {
case "stop": stop(); break;
case "start": required(); void start(id, args); break;
case "ready": result = native?.ready ?? false; break;
case "on": {
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
break;
}
case "off":
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
break;
case "channel-open": {
const name = args[0] as string | undefined;
channels.set(id, required().open_channel(name));
break;
}
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
case "channel-send": if (bytes) {
channels.get(id)?.send_rrd(bytes);
} break;
case "channel-close":
try { channels.get(id)?.close(); } finally {
channels.delete(id);
}
break;
case "get-camera-eye": result = readNativeRerunCameraEye(
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
); break;
case "open": required().open(args[0]); break;
case "close": required().close(args[0]); break;
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
case "get_active_recording_id": result = required().get_active_recording_id(); break;
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
case "get_playing": result = required().get_playing(args[0]); break;
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
case "set_playing": required().set_playing(args[0], args[1]); break;
default: throw new Error("Unsupported recorded viewer command");
}
return JSON.stringify({ result });
} catch (error) {
// A native Error also has a foreign prototype. Return text, never throw
// that object into a parent Promise/callback retained after disposal.
return JSON.stringify({ error: String(error) });
}
},
};
}
@@ -0,0 +1,12 @@
/** Only parent-owned inputs and primitive strings cross the recorded realm.
* An SDK instance, Promise, DOM node or native callback never becomes a value
* retained by React. This is not a second playback clock.
*/
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
export type RerunNotify = (message: string) => void;
export interface RerunFrameApi {
version: 2;
connect: (notify: RerunNotify) => void;
invoke: RerunInvoke;
}
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
@@ -0,0 +1,66 @@
import {useEffect, useRef, useState} from 'react';
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
import type {RecordedPointColorLoadState} from '../RerunViewport';
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
sourceGeneration?: string;
}) {
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
const loadRef = useRef(onLoad); loadRef.current = onLoad;
const failed = useRef(false);
const [retry, setRetry] = useState(0);
const key = settings ? pointDisplayKey(settings) : '';
const percent = settings?.pointDecimationPercent ?? 0;
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
const matched = sameSource && applied?.key === key;
useEffect(() => {
// A new settings interaction retries a failed request, but accumulation or
// size edits must not cancel an already-running preparation of the same key.
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
}, [settings]);
useEffect(() => {
if (!endpoint || !settings || !ready) return;
if (percent === 0 || percent === 100) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
return;
}
if (matched) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: 'Прореженное облако готово.'});
return;
}
const owner = ownerRef.current();
if (!owner) return;
const abort = new AbortController();
failed.current = false;
// Never append another generation at the same temporal entity path: an
// accumulated range would otherwise draw older samples as well.
const bank = crypto.randomUUID().replaceAll('-', '');
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
if (!abort.signal.aborted) owner.send(chunk);
}, undefined, sourceGeneration).then(bytes => {
if (abort.signal.aborted) return;
setApplied({endpoint, revision, key, bank, sourceGeneration});
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
message: 'Прореженное облако готово.'});
}).catch(() => {
if (!abort.signal.aborted) {
failed.current = true;
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
}
});
return () => abort.abort();
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
hidePoints: percent === 100};
}
@@ -0,0 +1,25 @@
import type {RoverState} from '../../core/fleet/useRoverControl';
import {drivePositions} from '../../../../../plugins/vesc/frontend/src/model';
const number=(v:number|undefined,digits=1)=>typeof v==='number'&&Number.isFinite(v)?v.toLocaleString('ru-RU',{maximumFractionDigits:digits}):'—';
export function RoverTelemetry({state}:{state:RoverState}){
const devices=state.snapshot.devices??[],profile=state.snapshot.profile;
const positions=drivePositions(profile?.layout??null);
const assigned=Object.entries(profile?.bindings??{}).map(([slot,binding])=>({
id:binding.device_id,uuid:binding.uuid,label:positions[slot]??slot,
reading:devices.find(d=>d.id===binding.device_id&&d.uuid===binding.uuid),
}));
const readings=[...assigned,...devices.filter(d=>!assigned.some(a=>a.id===d.id&&a.uuid===d.uuid)).map(d=>({...d,reading:d}))];
return <div className="rover-telemetry">
{!state.fresh?<p>Нет свежих показаний VESC.</p>:!readings.length?<p>Получение показаний VESC…</p>:readings.map(d=>{
const fresh=!!d.reading&&d.reading.age_ms<1000,v=fresh?d.reading?.values:undefined;
return <section key={d.uuid}><strong>{d.label}</strong><span>VESC {d.uuid.slice(0,6).toUpperCase()}{!d.reading?' · Нет связи':!fresh?' · Нет свежих данных':''}</span>
<dl><div><dt>Ток мотора</dt><dd>{number(v?.motor_current_a)} А</dd></div>
<div><dt>Ток батареи</dt><dd>{number(v?.input_current_a)} А</dd></div>
<div><dt>Напряжение</dt><dd>{number(v?.input_voltage_v)} В</dd></div>
<div><dt>Обороты, ERPM</dt><dd>{number(v?.erpm,0)}</dd></div>
<div><dt>Контроллер</dt><dd>{number(v?.mos_temperature_c)} °C</dd></div>
<div><dt>Ошибка VESC</dt><dd>{v?v.fault_code===0?'Нет':String(v.fault_code):'—'}</dd></div></dl>
</section>;
})}
</div>;
}
@@ -0,0 +1,70 @@
import {lazy,Suspense,useCallback,useEffect,useRef,useState} from 'react';
import {createPortal} from 'react-dom';
import {Button,Icon,IconButton,KeyButton,LoadingRegion,Select,StatusBadge,Switch,TextField,ToastStack,Window} from '@nodedc/ui-react';
import {keysFor,roverSettings,type RoverMode} from '../../core/fleet/roverInput';
import {bindRoverHoldInput} from '../../core/fleet/roverHoldInput';
import type {RoverController} from '../../core/fleet/useRoverControl';
import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
import './rover.css';
const Scene=lazy(()=>import('./playcanvas/PlayCanvasViewer'));
const labels:Record<string,string>={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'};
export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
const storage=`missioncore.rover-view.v1:${vehicleID}`;
const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}});
const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState<Set<string>>(new Set());
const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|null>(null);
const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]);
useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
useEffect(()=>{if(!active)stop();},[active,stop]);
useEffect(()=>{
if(!c.ready||!active||open||!stage.current)return;
const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
input.current=binding;c.setInputGuard(binding.valid);
return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;};
},[c.ready,active,open,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
const showSettings=()=>{stop();setOpen(true);};
const preparing=c.pending||(c.armed&&!c.ready);
const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null;
const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
return <>
{createPortal(<IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton>,header.actionsTarget)}
{createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером"
onBlur={e=>{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
{settings.model==='dcd006-v020'?<LoadingRegion className="rover-view__scene" loading={modelState==='loading'} label="Загрузка модели ровера">
<Suspense fallback={null}><Scene modelUrl="/rover-scene/dcd006-v020.glb" cameraAutoRotate={false} onModelState={setModelState}/></Suspense>
{modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
<div className="rover-view__status"><StatusBadge tone={tone}>{status}</StatusBadge></div>
{c.ready&&<div className="rover-view__controls">
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}>
{keysFor(settings.mode).map(code=><KeyButton key={code} className={`rover-key-${code}`} label={settings.mode==='arcade'?({KeyW:'Вперёд · W',KeyS:'Назад · S',KeyA:'Налево · A',KeyD:'Направо · D'}[code]??code):({KeyQ:'Левая сторона вперёд · Q',KeyA:'Левая сторона назад · A',KeyE:'Правая сторона вперёд · E',KeyD:'Правая сторона назад · D'}[code]??code)}
disabled={!c.ready||!active} pressed={held.has(code)}
onPointerDown={e=>{if(!c.ready||!e.isTrusted||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);stage.current?.focus();input.current?.pointerDown(e.pointerId,code);}}
onPointerUp={e=>input.current?.pointerUp(e.pointerId)}
onPointerCancel={e=>input.current?.pointerCancel(e.pointerId)}
onLostPointerCapture={e=>input.current?.pointerCancel(e.pointerId)}>{code.slice(3)}</KeyButton>)}
</div>
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
</div>}
<div className="rover-view__authority">
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
</div>
</div>
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings">
<Select label="Тип управления" value={settings.mode} options={[{value:'arcade',label:'Аркадный · W A S D'},{value:'tank',label:'Танковый · Q A / E D'}]} onChange={mode=>setSettings(v=>({...v,mode:mode as RoverMode}))}/>
<Select label="3D-модель" value={settings.model} options={[{value:'',label:'Не выбрана'},{value:'dcd006-v020',label:'DCD-006 · полный ровер v020'}]} onChange={model=>setSettings(v=>({...v,model}))}/>
<TextField label="Предел тока каждого мотора, А" type="number" min={.5} max={30} step={.5} value={settings.currentA} onChange={e=>setSettings(v=>({...v,currentA:Number(e.target.value)}))}/>
<TextField label="Скорость, ERPM" hint="300–3000" description="Электрические обороты двигателя. Скорость по земле требует параметров трансмиссии." type="number" min={300} max={3000} step={100} value={settings.maxErpm} onChange={e=>setSettings(v=>({...v,maxErpm:Number(e.target.value)}))}/>
<Switch label="Моторы остановлены, движение можно начинать" checked={confirmed} onChange={setConfirmed}/>
{availability&&<StatusBadge tone="warning">{availability}</StatusBadge>}
<p>Движение — только при удержании клавиш или кнопок. При уходе из окна команда снимается. Команда с пульта прекращает удалённое управление.</p>
<Button variant="primary" tone="neutral" disabled={!confirmed||!c.canArm||!(settings.currentA>=.5&&settings.currentA<=30&&settings.maxErpm>=300&&settings.maxErpm<=3000)} loading={c.pending}
onClick={async()=>{const acquired=await c.arm(settings.currentA,settings.maxErpm);if(acquired){setOpen(false);if(document.hasFocus())stage.current?.focus();}}}>Включить управление</Button>
</div></Window>
<ToastStack onDismiss={c.clearError} items={c.error?[{id:'rover-control-error',tone:'error',title:'Управление остановлено',description:c.error}]:[]}/>
</>;
}
@@ -0,0 +1,16 @@
{
"source": "NodeDC ThreeDAssetNode / PlayCanvasViewer",
"files": {
"PlayCanvasViewer.tsx": "34cd6df2e47a7714288aeb5afb2859b1058eb91dc1819abbf296c6a8937a1533",
"playcanvasPostFx.ts": "91cfb7f8ba821fef14681c6c25a61f430ce25aae65b0f181d359f38e37ccd014",
"sceneTree.ts": "eead378c2402f69e2c68cd1fc2a5ec9028555dced53858c600c599f613dda516",
"environment-map.png": "793f72dce207c1a4d2bdb262610f688bd655a79066298b672b642f228cf7d230"
},
"adaptations": [
"Domain asset URL and error/loading callbacks",
"No fallback to a different vehicle",
"Removed unused node-editor type helper; rendering settings unchanged",
"Observe the host pane on resize; preserve responsive canvas CSS sizing",
"Frame the complete rover using the donor 28-degree FOV and current viewport aspect"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,393 @@
export type PostFxSettings = {
lighting: {
exposure: number
skyBoxIntensity: number
}
envAtlas: {
enabled: boolean
background: boolean
reflection: boolean
intensity: number
reflectionIntensity: number
brightness: number
contrast: number
saturation: number
toneMapping: number
mip: number
rotation: number
}
skybox: {
enabled: boolean
background: boolean
reflection: boolean
intensity: number
reflectionIntensity: number
mip: number
rotation: number
colorA: string
colorB: string
}
rendering: {
backgroundColor: string
wireframe: boolean
renderFormat: number
renderFormatFallback0: number
renderFormatFallback1: number
stencil: boolean
renderTargetScale: number
samples: number
sharpness: number
toneMapping: number
sceneColorMap: boolean
sceneDepthMap: boolean
fog: 'none' | 'linear' | 'exp' | 'exp2'
fogColor: string
fogRange: [number, number]
fogDensity: number
fogStart: number
fogEnd: number
}
grid: {
enabled: boolean
colorX: string
colorZ: string
colorMain: string
alphaX: number
alphaZ: number
alphaMain: number
dotsEnabled: boolean
dotsColor: string
dotsAlpha: number
dotsDiameter: number
crossEnabled: boolean
crossColor: string
crossAlpha: number
crossLength: number
crossWidth: number
fadeStart: number
fadeEnd: number
}
ssao: {
type: 'none' | 'lighting' | 'combine'
blurEnabled: boolean
intensity: number
radius: number
samples: number
power: number
minAngle: number
scale: number
}
bloom: {
enabled: boolean
intensity: number
lastMipLevel: number
}
chromaticAberration: {
enabled: boolean
intensity: number
}
taa: {
enabled: boolean
jitter: number
}
grading: {
enabled: boolean
brightness: number
contrast: number
saturation: number
tint: string
}
lut: {
intensity: number
textureUrl?: string | null
}
vignette: {
enabled: boolean
intensity: number
inner: number
outer: number
curvature: number
color: string
}
directionalLight: {
enabled: boolean
color: string
intensity: number
azimuth: number
elevation: number
castShadows: boolean
shadowIntensity: number
shadowDistance: number
shadowResolution: number
shadowBias: number
normalOffsetBias: number
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
vsmBlurSize: number
}
shadowCatcher: {
enabled: boolean
size: number
yOffset: number
lightIntensity: number
lightColor: string
lightAzimuth: number
lightElevation: number
shadowIntensity: number
shadowDistance: number
shadowResolution: number
shadowBias: number
normalOffsetBias: number
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
vsmBlurSize: number
}
}
export const DEFAULT_POSTFX: PostFxSettings = {
lighting: {
exposure: 1.21,
skyBoxIntensity: 0.86,
},
envAtlas: {
enabled: true,
background: false,
reflection: true,
intensity: 0.76,
reflectionIntensity: 0.6,
brightness: 1.65,
contrast: 1,
saturation: 1,
toneMapping: 0,
mip: 1,
rotation: 247,
},
skybox: {
enabled: false,
background: false,
reflection: true,
intensity: 0.62,
reflectionIntensity: 1,
mip: 0,
rotation: 0,
colorA: '#c5bfbf',
colorB: '#c7bcbc',
},
rendering: {
backgroundColor: '#111113',
wireframe: false,
renderFormat: 18,
renderFormatFallback0: 12,
renderFormatFallback1: 14,
stencil: false,
renderTargetScale: 1,
samples: 4,
sharpness: 0,
toneMapping: 4,
sceneColorMap: false,
sceneDepthMap: false,
fog: 'exp',
fogColor: '#dcc2ff',
fogRange: [0, 100],
fogDensity: 0.008,
fogStart: 0,
fogEnd: 100,
},
grid: {
enabled: true,
colorX: '#ffffff',
colorZ: '#ffffff',
colorMain: '#7a3cff',
alphaX: 0.18,
alphaZ: 0.18,
alphaMain: 0.6,
dotsEnabled: false,
dotsColor: '#ffffff',
dotsAlpha: 0.5,
dotsDiameter: 0.06,
crossEnabled: false,
crossColor: '#ffffff',
crossAlpha: 0.5,
crossLength: 0.5,
crossWidth: 0.06,
fadeStart: 0,
fadeEnd: 0,
},
ssao: {
type: 'none',
blurEnabled: true,
intensity: 0.5,
radius: 30,
samples: 12,
power: 6,
minAngle: 10,
scale: 1,
},
bloom: {
enabled: true,
intensity: 0.03,
lastMipLevel: 4,
},
chromaticAberration: {
enabled: true,
intensity: 30,
},
taa: {
enabled: false,
jitter: 1,
},
grading: {
enabled: true,
brightness: 0.837,
contrast: 1.1,
saturation: 1.126,
tint: '#ffffff',
},
lut: {
intensity: 1,
textureUrl: null,
},
vignette: {
enabled: true,
intensity: 1,
inner: 0.25,
outer: 1.52,
curvature: 0.78,
color: '#000000',
},
directionalLight: {
enabled: true,
color: '#ffffff',
intensity: 0.4,
azimuth: 0,
elevation: 0,
castShadows: false,
shadowIntensity: 0.5,
shadowDistance: 16,
shadowResolution: 1024,
shadowBias: 0,
normalOffsetBias: 0,
shadowType: 'vsm16',
vsmBlurSize: 8,
},
shadowCatcher: {
enabled: true,
size: 9,
yOffset: 0.001,
lightIntensity: 0.3,
lightColor: '#ffffff',
lightAzimuth: 20,
lightElevation: 60,
shadowIntensity: 0.29,
shadowDistance: 16,
shadowResolution: 2048,
shadowBias: 0,
normalOffsetBias: 0,
shadowType: 'vsm16',
vsmBlurSize: 8,
},
}
function isObject(value: any): value is Record<string, any> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | null): PostFxSettings {
if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings
const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX))
const merge = (target: any, source: any) => {
if (!isObject(source)) return
for (const [key, value] of Object.entries(source)) {
if (isObject(value)) {
if (!isObject(target[key])) target[key] = {}
merge(target[key], value)
} else {
target[key] = value
}
}
}
merge(out, raw)
const legacy: any = raw as any
if (legacy?.hdrLight) {
const src = legacy.hdrLight
const env = legacy?.envAtlas ?? {}
if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled
if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox
if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity
if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip
if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation
}
if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) {
if (typeof legacy.hdrReflection.enabled === 'boolean') {
out.envAtlas.reflection = legacy.hdrReflection.enabled
}
}
if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') {
out.envAtlas.reflectionIntensity = out.envAtlas.intensity
}
if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') {
out.envAtlas.brightness = 1
}
if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') {
out.envAtlas.contrast = 1
}
if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') {
out.envAtlas.saturation = 1
}
if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') {
out.envAtlas.toneMapping = 0
}
if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') {
out.skybox.reflectionIntensity = out.skybox.intensity
}
if (out.grid && typeof out.grid.enabled !== 'boolean') {
out.grid.enabled = DEFAULT_POSTFX.grid.enabled
}
if (out.grid && typeof out.grid.colorX !== 'string') {
out.grid.colorX = DEFAULT_POSTFX.grid.colorX
}
if (out.grid && typeof out.grid.colorZ !== 'string') {
out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ
}
if (out.grid && typeof out.grid.colorMain !== 'string') {
out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain
}
if (out.grid && typeof out.grid.alphaX !== 'number') {
out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX
}
if (out.grid && typeof out.grid.alphaZ !== 'number') {
out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ
}
if (out.grid && typeof out.grid.alphaMain !== 'number') {
out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain
}
if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') {
out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled
}
if (out.grid && typeof out.grid.dotsColor !== 'string') {
out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor
}
if (out.grid && typeof out.grid.dotsAlpha !== 'number') {
out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha
}
if (out.grid && typeof out.grid.dotsDiameter !== 'number') {
out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter
}
if (out.grid && typeof out.grid.crossEnabled !== 'boolean') {
out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled
}
if (out.grid && typeof out.grid.crossColor !== 'string') {
out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor
}
if (out.grid && typeof out.grid.crossAlpha !== 'number') {
out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha
}
if (out.grid && typeof out.grid.crossLength !== 'number') {
out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength
}
if (out.grid && typeof out.grid.crossWidth !== 'number') {
out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth
}
return out as PostFxSettings
}
@@ -0,0 +1,20 @@
import type * as pc from 'playcanvas'
export type SceneTreeNode = {
id: string
name: string
enabled: boolean
children: SceneTreeNode[]
}
export function buildSceneTree(root: pc.GraphNode, map: Map<string, pc.Entity>): SceneTreeNode {
const id = String((root as any).getGuid?.() ?? (root as any)._guid ?? root.name)
const children = (root.children || []).map((c) => buildSceneTree(c, map))
map.set(id, root as pc.Entity)
return {
id,
name: root.name || 'Entity',
enabled: root.enabled !== false,
children,
}
}
@@ -0,0 +1,20 @@
.rover-view { position:relative; height:100%; min-height:200px; overflow:hidden; container-type:inline-size; }
.rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; }
.rover-view .rover-view__scene { position:absolute; inset:0; }
.rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); }
.rover-view__status { position:absolute; top:var(--nodedc-space-3); left:var(--nodedc-space-3); }
.rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; }
.rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); }
.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); }
.rover-key-KeyW { grid-column:2; }.rover-key-KeyA { grid-column:1; grid-row:2; }.rover-key-KeyS { grid-column:2; grid-row:2; }.rover-key-KeyD { grid-column:3; grid-row:2; }
.rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; }
.rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); }
@container (max-width: 380px) {
.rover-view__authority { top:48px; bottom:auto; }
}
.rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); }
.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); }
.rover-telemetry { height:100%; overflow:auto; padding:var(--nodedc-space-3); display:flex; flex-wrap:wrap; align-content:start; gap:var(--nodedc-space-4); font-size:var(--nodedc-font-size-sm); }
.rover-telemetry section { flex:1; min-width:170px; }.rover-telemetry section > span { display:block; color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-xs); margin-top:var(--nodedc-space-1); }
.rover-telemetry dl { display:grid; gap:var(--nodedc-space-1); margin-bottom:0; }.rover-telemetry dl > div { display:flex; justify-content:space-between; gap:var(--nodedc-space-3); }
.rover-telemetry dt { color:var(--nodedc-text-secondary); }.rover-telemetry dd { margin:0; font-variant-numeric:tabular-nums; }
@@ -0,0 +1,26 @@
import { FieldFrame, Select } from "@nodedc/ui-react";
import type { AiModules, AiSelection } from "../../core/simulation/aiPolygon";
const labels = { segmentation: "Поверхность", detection: "Объекты", geometry: "Рельеф",
range: "Расстояния", motion: "Навигация", policy: "Поведение" };
export function AiCompositionFields({ modules, selection, disabled, onChange }: {
modules: AiModules; selection: AiSelection; disabled: boolean; onChange: (value: AiSelection) => void;
}) {
return <section className="ai-polygon__section" aria-label="Композиция AI">
<h3>Модули AI</h3>
{modules.catalog.groups.filter(({ modules: options }) => options.length > 0).map(({ group, modules: options }) =>
<FieldFrame key={group} label={labels[group]}>
<Select label={`Модуль: ${labels[group]}`} disabled={disabled}
value={selection.selections.find((row) => row.group === group)?.module_id ?? ""}
options={options.map((module) => ({ value: module.moduleId, label: module.label }))}
onChange={(value) => {
const module = options.find((item) => item.moduleId === value);
if (!module) return;
onChange({ ...selection, selections: [...selection.selections.filter((row) => row.group !== group), {
group, module_id: module.moduleId, module_sha256: module.moduleSha256, parameters: module.defaults,
}] });
}} />
</FieldFrame>)}
</section>;
}
@@ -0,0 +1,28 @@
import { useState } from "react";
import { Button, Icon, ToastStack } from "@nodedc/ui-react";
import { controlAiRun, type AiRun } from "../../core/simulation/aiPolygon";
export function AiRunActions({ run, onChange }: { run: AiRun; onChange: (run: AiRun) => void }) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const terminal = ["completed", "stopped", "failed"].includes(run.state);
const pending = !terminal && (run.control_sequence ?? 0) > (run.telemetry?.control_sequence ?? 0);
const command = (value: "pause" | "play" | "stop") => {
setBusy(value); setError(null);
void controlAiRun(run, value).then(onChange).catch((caught: Error) => setError(caught.message))
.finally(() => setBusy(null));
};
return <>
{!terminal ? <>
<Button disabled={!!busy || pending || ["starting", "stopping", "disconnected"].includes(run.state)}
variant={run.state === "ready" ? "primary" : "secondary"}
loading={busy === "pause" || busy === "play"}
onClick={() => command(run.state === "running" ? "pause" : "play")}>
{run.state === "starting" ? "Подготовка…" : run.state === "ready" ? "Запустить AI" : run.state === "running" ? "Пауза" : "Продолжить"}
</Button>
<Button disabled={!!busy || run.state === "stopping"} loading={busy === "stop" || run.state === "stopping"}
icon={<Icon name="stop" />} onClick={() => command("stop")}>Завершить симуляцию</Button>
</> : null}
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Команда не выполнена", description: error }]} onDismiss={() => setError(null)} /> : null}
</>;
}
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { StatusBadge } from "@nodedc/ui-react";
import { type AiRun } from "../../core/simulation/aiPolygon";
const STATES: Record<AiRun["state"], string> = {
starting: "Подготовка симуляции", ready: "Сцена открыта · AI выключен",
running: "AI управляет ровером", paused: "Пауза · движение остановлено",
stopping: "Завершаем симуляцию и inference…", completed: "Симуляция завершена",
stopped: "Симуляция и inference остановлены", failed: "Симуляция прервана",
disconnected: "Нет связи с Worker · состояние уточняется",
};
const REASONS: Record<string, string> = {
road: "Движение по тропе", obstacle: "Остановка перед препятствием", "no-road": "Путь не найден",
uncertain: "Недостаточно уверенности", "inference-error": "Inference недоступен",
replanning: "Ищем другой проезд", stuck: "Ровер застрял · прогон остановлен",
"goal-reached": "Цель достигнута", unstable: "Остановка: опасный наклон",
waiting: "Ожидаем возможность движения",
};
export function AiRunView({ run, connection }: { run: AiRun; connection: ReactNode }) {
const terminal = ["completed", "stopped", "failed"].includes(run.state);
const telemetry = run.telemetry;
const pending = !terminal && (run.control_sequence ?? 0) > (telemetry?.control_sequence ?? 0);
const preparingModels = run.state === "running" && run.phase === "models";
const reason = telemetry?.stop_reason === "unstable" ? "Остановка: опасный наклон"
: telemetry?.stop_reason === "stale-camera" || telemetry?.stop_reason === "stale-command"
? "Остановка: решение AI устарело" : telemetry?.stop_reason === "inference-error"
? "Остановка: inference недоступен" : telemetry?.decision?.reason === "road" && Math.abs(telemetry.speed_mps) < 0.01
? "Ровер не движется" : REASONS[telemetry?.decision?.reason ?? ""];
return <section className="ai-polygon__run" aria-label="Управление симуляцией">
<div className="ai-polygon__telemetry-row">
<div className="ai-polygon__facts">
{run.state !== "ready" && run.state !== "running" || preparingModels || pending ? <StatusBadge tone={run.state === "failed" || run.state === "disconnected" ? "warning" : "neutral"}>
{preparingModels ? "Загружаем AI-модели" : pending && run.state !== "stopping" ? "Worker выполняет команду…" : STATES[run.state]}
</StatusBadge> : null}
{telemetry && !terminal && run.state !== "stopping" && run.state !== "disconnected" ? <div className="ai-polygon__facts">
{run.state === "running" && !preparingModels && reason ? <strong>{reason}</strong> : null}
<span>Скорость: {Math.abs(telemetry.speed_mps).toFixed(2)} м/с</span>
<span>Время мира: {(telemetry.simulation_time_ns / 1e9).toFixed(1)} с</span>
{run.state === "running" ? <>
<span>Симуляция: {telemetry.rtf.toFixed(2)}×</span>
<span>Камера: {telemetry.sensor_fps.toFixed(1)} FPS</span>
<span>AI: {telemetry.ai_hz.toFixed(1)} решений/с</span>
{telemetry.inference_ms !== null ? <span>Inference: {telemetry.inference_ms.toFixed(0)} мс</span> : null}
{telemetry.frame_age_ms !== null ? <span>Возраст кадра: {telemetry.frame_age_ms.toFixed(0)} мс</span> : null}
</> : null}
</div> : null}
</div>
{connection}
</div>
{run.state === "failed" && run.message ? <p>{run.message}</p> : null}
</section>;
}
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from "react";
import { Button, Icon, ProgressBar, TextField, ToastStack, Window } from "@nodedc/ui-react";
import { importAiWorld, type AiSource, type AiWorld } from "../../core/simulation/aiPolygon";
export function AiWorldImport({ source, resume, onClose, onImported }: {
source: AiSource | null; resume?: AiWorld; onClose: () => void; onImported: (world: AiWorld) => void;
}) {
const [draft, setDraft] = useState<AiSource>(source ?? {
name: "", author: "", license: "", source_url: "",
});
const [file, setFile] = useState<File | null>(null);
const [pending, setPending] = useState<AiWorld | undefined>(resume);
const [progress, setProgress] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const picker = useRef<HTMLInputElement>(null);
const controller = useRef<AbortController | null>(null);
useEffect(() => () => controller.current?.abort(), []);
const submit = async () => {
if (!file || busy) return;
setBusy(true); setError(null);
const abort = new AbortController(); controller.current = abort;
try { onImported(await importAiWorld(file, draft, setProgress, abort.signal, pending, setPending)); }
catch (caught) { if (!abort.signal.aborted) setError(caught instanceof Error ? caught.message : "Импорт не завершён."); }
finally { if (!abort.signal.aborted) setBusy(false); }
};
return <Window open onClose={onClose} title={resume ? "Продолжить импорт" : "Импорт локации"}
footer={<Button variant="primary" loading={busy} disabled={!file || !draft.name.trim() || !draft.author.trim() || !draft.license.trim()}
onClick={() => void submit()}>Импортировать</Button>}>
<div className="ai-polygon__form">
<TextField label="Название" value={draft.name} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
<TextField label="Автор" value={draft.author} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, author: e.target.value })} />
<TextField label="Лицензия" value={draft.license} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, license: e.target.value })} />
<TextField label="Страница источника" value={draft.source_url ?? ""} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, source_url: e.target.value })} />
<input ref={picker} type="file" accept=".ply" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<Button disabled={busy} icon={<Icon name="upload" />} onClick={() => picker.current?.click()}>
{file?.name ?? "Выбрать Gaussian PLY"}
</Button>
{busy ? <ProgressBar value={progress} label={`Передано ${Math.round(progress * 100)}%`} /> : null}
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Импорт не завершён", description: error }]} onDismiss={() => setError(null)} /> : null}
</div>
</Window>;
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import { Button, TextField, TextAreaField, ToastStack } from "@nodedc/ui-react";
import { configureAiWorld, type AiWorld, type AiWorldSettings } from "../../core/simulation/aiPolygon";
export function AiWorldSetup({ world, disabled, onSaved }: {
world: AiWorld; disabled: boolean; onSaved: (world: AiWorld) => void;
}) {
const [draft, setDraft] = useState<AiWorldSettings>(world.settings);
const [route, setRoute] = useState((world.settings.route_xy ?? []).map((p) => p.join(" ")).join("\n"));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const field = (label: string, value: number, change: (value: number) => void) =>
<TextField label={label} type="number" step="any" value={Number.isFinite(value) ? value : ""}
disabled={disabled || busy} onChange={(e) => change(e.target.value === "" ? NaN : Number(e.target.value))} />;
return <div className="ai-polygon__setup">
{field("Метров на единицу сцены", draft.meters_per_unit, (v) => setDraft({ ...draft, meters_per_unit: v }))}
{([0, 1, 2] as const).map((axis) => <div key={axis}>
{field(`Поворот ${["X", "Y", "Z"][axis]}, °`, draft.rotation_degrees[axis], (v) => {
const rotation = [...draft.rotation_degrees] as [number, number, number]; rotation[axis] = v;
setDraft({ ...draft, rotation_degrees: rotation });
})}
</div>)}
{field("Уровень грунта Z, м", draft.ground_z, (v) => setDraft({ ...draft, ground_z: v }))}
{field("Старт X, м", draft.spawn_xy[0], (v) => setDraft({ ...draft, spawn_xy: [v, draft.spawn_xy[1]] }))}
{field("Старт Y, м", draft.spawn_xy[1], (v) => setDraft({ ...draft, spawn_xy: [draft.spawn_xy[0], v] }))}
{field("Направление, °", draft.heading_degrees, (v) => setDraft({ ...draft, heading_degrees: v }))}
{field("Высота камеры, м", draft.camera_height_m, (v) => setDraft({ ...draft, camera_height_m: v }))}
{field("Скорость до, м/с", draft.max_speed_mps, (v) => setDraft({ ...draft, max_speed_mps: v }))}
<TextAreaField label="Точки маршрута, X Y в метрах" rows={3} value={route}
hint="Каждая точка — с новой строки. Пустое поле: движение по видимой тропе."
disabled={disabled || busy} onChange={(e) => setRoute(e.target.value)} />
<Button disabled={disabled} loading={busy} onClick={() => {
const points = route.trim() ? route.trim().split(/\n+/).map((line) => line.trim().split(/\s+/).map(Number)) : [];
if (points.length > 32 || points.some((p) => p.length !== 2 || p.some((v) => !Number.isFinite(v)))) {
setError("Укажите не более 32 точек: два числа X Y в каждой строке."); return;
}
setBusy(true); setError(null);
void configureAiWorld(world, { ...draft, route_xy: points as [number, number][], prepared: true }).then(onSaved)
.catch((caught: Error) => setError(caught.message)).finally(() => setBusy(false));
}}>Сохранить старт и масштаб</Button>
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Настройки не сохранены", description: error }]} onDismiss={() => setError(null)} /> : null}
</div>;
}
@@ -0,0 +1,133 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Button, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
import { setAiCamera, type AiCamera, type AiRun, type AiWorld } from "../../core/simulation/aiPolygon";
import { useSimulationFullscreen } from "./useSimulationFullscreen";
// NVIDIA has process-global browser listeners. Serialize replacement/cleanup,
// including React StrictMode, without letting an old viewer terminate a new one.
let streamLifecycle: Promise<unknown> = Promise.resolve();
/** Decode the actual Worker stream. This component never loads a world or creates a 3D engine. */
export function AiWorldStream({ world, run, onBack, onChange, controls, headerActions, actionsHost }: {
world: AiWorld; run?: AiRun | null; onBack: () => void; onChange: (run: AiRun) => void; controls?: ReactNode;
headerActions?: ReactNode; actionsHost?: HTMLElement | null;
}) {
const frame = useRef<HTMLDivElement>(null);
const video = useRef<HTMLVideoElement>(null);
const id = useId().replaceAll(":", "");
const { expanded, toggle } = useSimulationFullscreen(frame);
const [state, setState] = useState<"connecting" | "live" | "failed" | "stalled">("connecting");
const [fps, setFps] = useState<number | null>(null);
const [revision, setRevision] = useState(0);
const [cameraError, setCameraError] = useState(false);
const endpoint = run?.worker?.stream;
const terminal = !run || ["completed", "stopped", "failed"].includes(run.state);
const connectable = !terminal && !!run?.telemetry?.stream_ready && !!endpoint;
const endpointKey = JSON.stringify(endpoint);
useEffect(() => {
if (!connectable || !endpoint || !video.current) return;
let disposed = false;
let stream: import("@nvidia/ov-web-rtc").AppStreamer | undefined;
let settle!: () => void;
const settled = new Promise<void>((resolve) => { settle = resolve; });
let callback = 0;
let lastFrame = performance.now();
let frames = 0;
let measuredAt = performance.now();
let connected = false;
setState("connecting"); setFps(null);
const element = video.current;
const received = () => {
if (disposed) return;
lastFrame = performance.now(); frames += 1; connected = true; setState("live");
callback = element.requestVideoFrameCallback(received);
};
callback = element.requestVideoFrameCallback(received);
const timer = setInterval(() => {
const now = performance.now();
setFps(frames * 1000 / (now - measuredAt));
frames = 0; measuredAt = now;
if (connected && now - lastFrame > 3000) setState("stalled");
}, 1000);
const setup = streamLifecycle.catch(() => undefined).then(async () => {
const sdk = await import("@nvidia/ov-web-rtc");
if (disposed) { settle(); return; }
stream = new sdk.AppStreamer();
await stream.connect({ streamSource: sdk.StreamType.DIRECT, logLevel: sdk.LogLevel.WARN,
streamConfig: { signalingServer: endpoint.server, signalingPort: endpoint.signaling_port,
signalingPath: "/", mediaServer: endpoint.server, mediaPort: endpoint.media_port,
width: endpoint.width, height: endpoint.height, fps: endpoint.fps,
videoElementId: `polygon-video-${id}`, audioElementId: `polygon-audio-${id}`,
autoLaunch: true, mic: false, enableAV1Support: false, fitStreamResolution: false,
maxReconnects: 1, reconnectDelay: 2000, connectivityTimeout: 5000,
onStart: (event) => {
if (event.status === sdk.EventStatus.SUCCESS || event.status === sdk.EventStatus.ERROR
|| event.status === sdk.EventStatus.CANCELED) settle();
if (!disposed && event.status === sdk.EventStatus.ERROR) setState("failed");
},
onStop: () => { settle(); if (!disposed) setState("stalled"); },
} });
if (stream.streamStatus !== sdk.StreamStatus.STARTING) settle();
}).catch(() => { settle(); if (!disposed) setState("failed"); });
streamLifecycle = setup;
return () => {
disposed = true; clearInterval(timer); element.cancelVideoFrameCallback(callback);
// connect() resolves with IN_PROGRESS; terminate() during STARTING does
// not dispose the SDK. Await its final callback before admitting a new owner.
streamLifecycle = setup.then(async () => {
await settled;
await stream?.terminate(false);
}).catch(() => undefined);
};
// A telemetry refresh must never reconnect or reload the stream.
}, [connectable, endpointKey, run?.run_id, id, revision]);
const mode = run?.camera ?? "follow";
const changeCamera = (camera: AiCamera) => {
if (!run) return;
setCameraError(false);
void setAiCamera(run, camera).then(onChange).catch(() => setCameraError(true));
};
return <div ref={frame} className="ai-polygon__stage">
{!expanded && actionsHost ? createPortal(headerActions, actionsHost) : null}
{expanded ? <div className="ai-polygon__head">
<div className="ai-polygon__title">
<IconButton label="Назад к локациям" onClick={onBack}><Icon name="chevron-left" /></IconButton>
<h2>{world.name}</h2>
</div>
<div className="ai-polygon__actions">{headerActions}</div>
</div> : null}
{controls ? <div className="ai-polygon__viewer-controls">{controls}</div> : null}
<div className="ai-polygon__viewer">
<div className="ai-polygon__viewer-toolbar">
<SegmentedControl<AiCamera> label="Вид симуляции" size="dense" value={mode} onChange={changeCamera}
items={[{ value: "follow", label: "За ровером", disabled: terminal },
{ value: "overview", label: "Обзор", disabled: terminal }, { value: "camera", label: "Камера AI", disabled: terminal }]} />
<div className="ai-polygon__actions">
{connectable ? <StatusBadge tone={state === "live" ? "success" : "warning"}>
{state === "live" ? `Видео · ${fps?.toFixed(0) ?? "—"} FPS` : "Ожидаем видео"}
</StatusBadge> : null}
<IconButton label={expanded ? "Свернуть симуляцию" : "Развернуть симуляцию"} onClick={toggle}>
<Icon name={expanded ? "minimize" : "expand"} />
</IconButton>
</div>
</div>
{cameraError ? <p role="alert">Не удалось переключить камеру.</p> : null}
<div className="ai-polygon__viewport">
<LoadingRegion loading={!terminal && (!connectable || state === "connecting")}
label={run?.state === "disconnected" ? "Восстанавливаем связь с Worker" : !connectable ? "Подготавливаем сцену на Worker" : "Подключаем видео"}>
<div className="ai-polygon__video" inert>
<video ref={video} id={`polygon-video-${id}`} autoPlay muted playsInline tabIndex={-1}
aria-label={`Видео симуляции ${world.name} с Worker`} />
<audio id={`polygon-audio-${id}`} muted />
</div>
</LoadingRegion>
{terminal ? <div className="ai-polygon__preview-error"><p>{run?.state === "failed" ? run.message ?? "Симуляция прервана." : "Симуляция завершена."}</p></div>
: connectable && (state === "failed" || state === "stalled") ? <div className="ai-polygon__preview-error" role="status">
<p>Видеопоток прерван. Состояние симуляции показано выше.</p>
<Button onClick={() => setRevision((value) => value + 1)}>Подключить видео</Button>
</div> : null}
</div>
</div>
</div>;
}
@@ -0,0 +1,26 @@
import { useEffect, useState, type RefObject } from "react";
export function useSimulationFullscreen(view: RefObject<HTMLDivElement | null>) {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const sync = () => setExpanded(!!view.current && document.fullscreenElement === view.current);
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !view.current || document.fullscreenElement !== view.current) return;
event.preventDefault();
event.stopPropagation();
void document.exitFullscreen().catch(() => undefined);
};
document.addEventListener("fullscreenchange", sync);
document.addEventListener("keydown", escape, true);
return () => {
document.removeEventListener("fullscreenchange", sync);
document.removeEventListener("keydown", escape, true);
};
}, [view]);
const toggle = () => {
if (!view.current) return;
void (document.fullscreenElement === view.current
? document.exitFullscreen() : view.current?.requestFullscreen())?.catch(() => undefined);
};
return { expanded, toggle };
}
@@ -6,19 +6,18 @@ interface TelemetrySeriesProps {
ceiling?: number; ceiling?: number;
} }
function linePoints(values: Array<number | null>, ceiling?: number): string { export function lineSegments(values: Array<number | null>, ceiling?: number): string[] {
const finite = values.filter((value): value is number => const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
value !== null && Number.isFinite(value), if (!finite.length) return [];
); const maximum = Math.max(ceiling ?? 0, ...finite, 1), denominator = Math.max(1, values.length - 1);
if (finite.length === 0) return ""; const minimum = Math.min(0, ...finite), span = maximum - minimum;
const maximum = Math.max(ceiling ?? 0, ...finite, 1); const segments:string[]=[];let segment:string[]=[];
const denominator = Math.max(1, values.length - 1); values.forEach((value,index)=>{
return values.map((value, index) => { if(value===null||!Number.isFinite(value)){if(segment.length)segments.push(segment.join(' '));segment=[];return;}
const x = index / denominator * 100; segment.push(`${(index/denominator*100).toFixed(2)},${(36-(Math.min(maximum,value)-minimum)/span*34).toFixed(2)}`);
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value)); });
const y = 36 - normalized / maximum * 34; if(segment.length)segments.push(segment.join(' '));
return `${x.toFixed(2)},${y.toFixed(2)}`; return segments;
}).join(" ");
} }
export function TelemetrySeries({ export function TelemetrySeries({
@@ -28,7 +27,7 @@ export function TelemetrySeries({
resource, resource,
ceiling, ceiling,
}: TelemetrySeriesProps) { }: TelemetrySeriesProps) {
const points = linePoints(values, ceiling); const segments = lineSegments(values, ceiling);
return ( return (
<div className="system-telemetry-series"> <div className="system-telemetry-series">
<div> <div>
@@ -45,7 +44,7 @@ export function TelemetrySeries({
aria-label={`${label}: ${value}`} aria-label={`${label}: ${value}`}
> >
<path d="M0 37 H100" /> <path d="M0 37 H100" />
{points ? <polyline points={points} /> : null} {segments.map((points,index)=><polyline key={index} points={points} />)}
</svg> </svg>
</div> </div>
); );
@@ -5,12 +5,14 @@ import type { WorkspaceDefinition } from "../productModel";
interface ApplicationPanelActionsOptions { interface ApplicationPanelActionsOptions {
definition: WorkspaceDefinition | null; definition: WorkspaceDefinition | null;
onAddVehicle?: () => void;
refreshRuntime: () => void; refreshRuntime: () => void;
resetConnectionScenario?: () => Promise<boolean>; resetConnectionScenario?: () => Promise<boolean>;
connectionScenarioResetting: boolean; connectionScenarioResetting: boolean;
saveWorkspaceLayout: () => Promise<void>; saveWorkspaceLayout: () => Promise<void>;
workspaceLayoutSaving: boolean; workspaceLayoutSaving: boolean;
systemUtilityActions: readonly ApplicationPanelUtilityAction[]; systemUtilityActions: readonly ApplicationPanelUtilityAction[];
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
} }
export function deviceRuntimeUtilityAction({ export function deviceRuntimeUtilityAction({
@@ -43,15 +45,18 @@ export function deviceRuntimeUtilityAction({
export function useApplicationPanelActions({ export function useApplicationPanelActions({
definition, definition,
onAddVehicle,
refreshRuntime, refreshRuntime,
resetConnectionScenario, resetConnectionScenario,
connectionScenarioResetting, connectionScenarioResetting,
saveWorkspaceLayout, saveWorkspaceLayout,
workspaceLayoutSaving, workspaceLayoutSaving,
systemUtilityActions, systemUtilityActions,
sessionOverview,
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] { }: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => { return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = []; const actions: ApplicationPanelUtilityAction[] = [];
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
if (definition?.kind === "device") { if (definition?.kind === "device") {
actions.push(deviceRuntimeUtilityAction({ actions.push(deviceRuntimeUtilityAction({
refreshRuntime, refreshRuntime,
@@ -68,14 +73,21 @@ export function useApplicationPanelActions({
}); });
} }
if (definition?.root === "system") actions.push(...systemUtilityActions); 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; return actions;
}, [ }, [
definition, definition,
onAddVehicle,
refreshRuntime, refreshRuntime,
resetConnectionScenario, resetConnectionScenario,
connectionScenarioResetting, connectionScenarioResetting,
saveWorkspaceLayout, saveWorkspaceLayout,
systemUtilityActions, systemUtilityActions,
workspaceLayoutSaving, workspaceLayoutSaving,
sessionOverview,
]); ]);
} }
@@ -1,8 +1,14 @@
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
import type { DeviceUiPlugin } from "../core/device-plugins/contracts"; import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin"; 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 // Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build. // statically reviewed device plugins are shipped in the current build.
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([ export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
xgridsK1Plugin, 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,vescSensorUi]);
@@ -11,6 +11,8 @@ import {
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext"; import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts"; import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry"; 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 { interface DevicePluginHostValue {
registry: DevicePluginRegistry; registry: DevicePluginRegistry;
@@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId(
export function DevicePluginHostProvider({ export function DevicePluginHostProvider({
plugins, plugins,
nodeSensorContributions = NO_NODE_SENSORS,
children, children,
}: { }: {
plugins: readonly DeviceUiPlugin[]; plugins: readonly DeviceUiPlugin[];
nodeSensorContributions?: readonly SensorUiContribution[];
children: ReactNode; children: ReactNode;
}) { }) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]); const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]);
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []); const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
const [selectedModelId, setSelectedModelId] = useState<string | null>(() => const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
restorePersistedDeviceModelId(registry, selectionStorage) restorePersistedDeviceModelId(registry, selectionStorage)
@@ -97,12 +97,24 @@ export interface DevicePluginHostActions {
activateAutomaticSpatialSource: () => void; 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 { export interface DevicePluginConnectionProps {
model: DeviceModelDefinition; model: DeviceModelDefinition;
host: DevicePluginHostActions; host: DevicePluginHostActions;
spatialActivity?: SpatialActivityPresentation;
} }
export interface DeviceUiPlugin { export interface DeviceUiPlugin {
sensorUi?: {
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
};
manifest: DevicePluginManifest; manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{ RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null; activeModel: DeviceModelDefinition | null;
@@ -6,8 +6,10 @@ import {
type DeviceUiPlugin, type DeviceUiPlugin,
type RegisteredDeviceModel, type RegisteredDeviceModel,
} from "./contracts"; } from "./contracts";
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
export interface DevicePluginRegistry { export interface DevicePluginRegistry {
readonly sensorContributions: readonly SensorUiContribution[];
readonly plugins: readonly DeviceUiPlugin[]; readonly plugins: readonly DeviceUiPlugin[];
readonly models: readonly RegisteredDeviceModel[]; readonly models: readonly RegisteredDeviceModel[];
resolveModel: (modelId: string) => RegisteredDeviceModel | null; resolveModel: (modelId: string) => RegisteredDeviceModel | null;
@@ -15,6 +17,7 @@ export interface DevicePluginRegistry {
export function createDevicePluginRegistry( export function createDevicePluginRegistry(
installedPlugins: readonly DeviceUiPlugin[], installedPlugins: readonly DeviceUiPlugin[],
nodeSensorContributions: readonly SensorUiContribution[] = [],
): DevicePluginRegistry { ): DevicePluginRegistry {
const pluginIds = new Set<string>(); const pluginIds = new Set<string>();
const modelIds = 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 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 { return {
sensorContributions: Object.freeze(sensorContributions),
plugins: Object.freeze([...installedPlugins]), plugins: Object.freeze([...installedPlugins]),
models: Object.freeze(models), models: Object.freeze(models),
resolveModel: (modelId) => modelById.get(modelId) ?? null, resolveModel: (modelId) => modelById.get(modelId) ?? null,
@@ -1,32 +1,9 @@
import { roots, type RootId } from "../../productModel"; import { roots, type RootId } from "../../productModel";
export type EnvironmentSurfaceId = "home" | RootId; export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video"; import { defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems, type EnvironmentBackground, type EnvironmentMediaItem, type EnvironmentMediaKind, type EnvironmentMediaSource, type EnvironmentPage } from "@nodedc/ui-core";
export type EnvironmentMediaSource = "file" | "url"; export { createEnvironmentMediaItem, appendEnvironmentMediaItem, removeEnvironmentMediaItem, inferEnvironmentMediaKind, defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems } from "@nodedc/ui-core";
export type { EnvironmentBackground, EnvironmentMediaItem, EnvironmentMediaKind, EnvironmentMediaSource, EnvironmentPage } from "@nodedc/ui-core";
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;
}
export interface EnvironmentSettings { export interface EnvironmentSettings {
revision: number; revision: number;
@@ -54,72 +31,19 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon", "polygon",
]; ];
export const defaultEnvironmentImageDurationSeconds = 10; function emptyBackground(): EnvironmentBackground { return { enabled: false, imageDurationSeconds: defaultEnvironmentImageDurationSeconds, items: [] }; }
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";
}
const defaultQuickActions: Record< const defaultQuickActions: Record<
EnvironmentSurfaceId, EnvironmentSurfaceId,
readonly [string | null, string | null] readonly [string | null, string | null]
> = { > = {
home: ["spatial-scene", "local-device"], home: ["spatial-scene", "vehicles"],
fleet: ["contour-health", "local-device"], fleet: ["vehicles", "contour-health"],
observation: ["spatial-scene", "cameras"], observation: ["cameras", "world-map"],
missions: ["mission-planner", null], missions: ["routes", null],
data: ["recordings", "datasets"], data: ["recordings", "datasets"],
system: ["modules", "integrations"], system: ["modules", "integrations"],
polygon: ["lab-archive", null], polygon: ["observatory", "local-device"],
}; };
export function defaultEnvironmentSettings(): EnvironmentSettings { export function defaultEnvironmentSettings(): EnvironmentSettings {
@@ -216,6 +140,10 @@ function decodeBackground(value: unknown, path: string): EnvironmentBackground {
}; };
} }
function currentQuickAction(value: string | null): string | null {
return value === "lab-archive" ? "observatory" : value;
}
function decodePage(value: unknown, path: string): EnvironmentPage { function decodePage(value: unknown, path: string): EnvironmentPage {
const record = requireRecord(value, path); const record = requireRecord(value, path);
return { return {
@@ -223,16 +151,16 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!, eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
title: requireString(record.title, `${path}.title`)!, title: requireString(record.title, `${path}.title`)!,
description: requireString(record.description, `${path}.description`)!, description: requireString(record.description, `${path}.description`)!,
primaryWorkspaceId: requireString( primaryWorkspaceId: currentQuickAction(requireString(
record.primary_workspace_id, record.primary_workspace_id,
`${path}.primary_workspace_id`, `${path}.primary_workspace_id`,
true, true,
), )),
secondaryWorkspaceId: requireString( secondaryWorkspaceId: currentQuickAction(requireString(
record.secondary_workspace_id, record.secondary_workspace_id,
`${path}.secondary_workspace_id`, `${path}.secondary_workspace_id`,
true, true,
), )),
background: decodeBackground(record.background, `${path}.background`), background: decodeBackground(record.background, `${path}.background`),
}; };
} }
@@ -0,0 +1,13 @@
import {createBoardLayoutStore,type BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
import {fleetRequest} from './useFleet';
const layouts=new Map<string,BoardLayoutStore>();
export function boardLayout(vehicleID:string):BoardLayoutStore {
let layout=layouts.get(vehicleID);
if(!layout){
const path=`/${encodeURIComponent(vehicleID)}/board-layout`;
layout=createBoardLayoutStore({read:()=>fleetRequest(path),patch:(section,open)=>fleetRequest(path,'PATCH',{section,open})});
layouts.set(vehicleID,layout);
}
return layout;
}
@@ -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,66 @@
import {keyDemand,keysFor,type Demand,type RoverMode} from './roverInput';
// The network heartbeat is not evidence that a physical key is still held.
// Allow the initial OS repeat delay, then require continuing keyboard evidence.
export const firstKeyLeaseMs=1000,repeatKeyLeaseMs=300;
export function bindRoverHoldInput(scope:HTMLElement,mode:RoverMode,callbacks:{
held:(keys:Set<string>)=>void;demand:(value:Demand)=>void;pause:()=>void;stop:()=>void;
},now:()=>number=()=>performance.now()){
const doc=scope.ownerDocument,win=doc.defaultView!;
const keyboard=new Set<string>(),pointers=new Map<number,string>();
let deadline=0,closed=false,suspended=false;
const clear=()=>{keyboard.clear();pointers.clear();deadline=0;callbacks.held(new Set());};
const pause=()=>{if(closed||suspended)return;suspended=true;clear();callbacks.pause();};
const stop=()=>{if(closed)return;closed=true;clear();callbacks.stop();};
const focused=()=>!doc.hidden&&doc.hasFocus()&&scope.contains(doc.activeElement);
const valid=()=>{
if(closed)return false;
if(!focused()||(keyboard.size>0&&now()>=deadline)){pause();return false;}
return true;
};
const publish=()=>{const held=new Set([...keyboard,...pointers.values()]);callbacks.held(held);callbacks.demand(keyDemand(mode,held));};
const down=(e:KeyboardEvent)=>{
if(!e.isTrusted||closed)return;
if(e.code==='Space'||e.code==='Escape'){e.preventDefault();stop();return;}
if(e.altKey||e.ctrlKey||e.metaKey){pause();return;}
if(!keysFor(mode).includes(e.code)||!valid())return;
if((e.target as HTMLElement)?.closest('input,textarea,select,[role=dialog],[role=listbox]')){pause();return;}
// A repeat delivered after focus returns cannot become a fresh press.
if(e.repeat&&!keyboard.has(e.code))return;
e.preventDefault();suspended=false;keyboard.add(e.code);
deadline=now()+(e.repeat?repeatKeyLeaseMs:firstKeyLeaseMs);publish();
};
const up=(e:KeyboardEvent)=>{
if(!e.isTrusted||closed||!keyboard.delete(e.code))return;
e.preventDefault();if(!keyboard.size)deadline=0;publish();
};
const blur=(e:Event)=>{if(e.target===win)pause();};
const focusOut=(e:FocusEvent)=>{if(scope.contains(e.target as Node)&&!scope.contains(e.relatedTarget as Node|null))pause();};
const focusIn=()=>{if(!focused())pause();};
const hidden=()=>{if(doc.hidden)pause();};
const outside=(e:Event)=>{if(!scope.contains(e.target as Node))pause();};
const pointerUp=(e:PointerEvent)=>{if(!closed&&pointers.delete(e.pointerId))publish();};
const pointerCancel=(e:PointerEvent)=>{if(pointers.has(e.pointerId))pause();};
win.addEventListener('keydown',down,true);win.addEventListener('keyup',up,true);
win.addEventListener('blur',blur,true);win.addEventListener('pagehide',stop);
doc.addEventListener('focusout',focusOut,true);doc.addEventListener('focusin',focusIn,true);
doc.addEventListener('visibilitychange',hidden);doc.addEventListener('pointerdown',outside,true);
doc.addEventListener('pointerup',pointerUp,true);doc.addEventListener('pointercancel',pointerCancel,true);
doc.addEventListener('contextmenu',pause,true);
const timer=win.setInterval(valid,50);
return {
valid,
pointerDown(id:number,code:string){if(valid()){suspended=false;pointers.set(id,code);publish();}},
pointerUp(id:number){if(!closed&&pointers.delete(id))publish();},
pointerCancel(id:number){if(pointers.has(id))pause();},
dispose(){
closed=true;clear();callbacks.demand({left:0,right:0});win.clearInterval(timer);
win.removeEventListener('keydown',down,true);win.removeEventListener('keyup',up,true);
win.removeEventListener('blur',blur,true);win.removeEventListener('pagehide',stop);
doc.removeEventListener('focusout',focusOut,true);doc.removeEventListener('focusin',focusIn,true);
doc.removeEventListener('visibilitychange',hidden);doc.removeEventListener('pointerdown',outside,true);
doc.removeEventListener('pointerup',pointerUp,true);doc.removeEventListener('pointercancel',pointerCancel,true);
doc.removeEventListener('contextmenu',pause,true);
},
};
}
@@ -0,0 +1,20 @@
export type RoverMode='arcade'|'tank';
export type Demand={left:number;right:number};
export const keysFor=(mode:RoverMode)=>mode==='arcade'?['KeyW','KeyA','KeyS','KeyD']:['KeyQ','KeyE','KeyA','KeyD'];
export function keyDemand(mode:RoverMode,held:ReadonlySet<string>):Demand {
const axis=(positive:string,negative:string)=>Number(held.has(positive))-Number(held.has(negative));
if(mode==='tank')return {left:axis('KeyQ','KeyA'),right:axis('KeyE','KeyD')};
const forward=axis('KeyW','KeyS'),turn=axis('KeyD','KeyA');
const scale=Math.max(1,Math.abs(forward)+Math.abs(turn));
return {left:(forward+turn)/scale,right:(forward-turn)/scale};
}
export const defaultRoverSettings={version:1,mode:'arcade' as RoverMode,currentA:30,maxErpm:2000,model:''};
export function roverSettings(value:unknown):typeof defaultRoverSettings {
const v=value as Partial<typeof defaultRoverSettings>|null;
return {...defaultRoverSettings,...(v?.version===1?{
mode:v.mode==='tank'?'tank':'arcade',
currentA:typeof v.currentA==='number'&&Number.isFinite(v.currentA)?Math.max(.5,Math.min(30,v.currentA)):30,
maxErpm:typeof v.maxErpm==='number'&&Number.isFinite(v.maxErpm)?Math.max(300,Math.min(3000,v.maxErpm)):2000,
model:v.model==='dcd006-v020'?v.model:'',
}:{} )};
}
@@ -0,0 +1,19 @@
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 {
configurationArchive:{list:(device,before)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations/${encodeURIComponent(version)}`)},
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,61 @@
import { useCallback, useEffect, useState } from "react";
export interface BoardHost {
hostname: string; os: string; architecture: string; cpus: number;
memory_kib: number | null; collected_at: string;
networks: { name: string; up: boolean; addresses: string[] }[];
usb: { port: string; product: string }[];
}
export interface Vehicle {
id: string; node_id: string; name: string; platform: string;
enrollment: "pending" | "paired" | "revoked" | "failed";
connectivity: "online" | "offline"; last_seen: number | null;
host: BoardHost | null; notice: string; revision: number;
}
export interface FleetPreview {
preview_id: string; node_id: string; name: string; host: BoardHost;
endpoint: string; expires_at: number;
}
export async function fleetRequest<T>(path = "", method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`/api/v1/fleet${path}`, {
method, credentials: "same-origin", cache: "no-store",
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(12000),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(typeof data.detail === "string" ? data.detail : "Не удалось выполнить действие с аппаратом.");
}
return response.json();
}
export function useFleet() {
const [items, setItems] = useState<Vehicle[] | null>(null);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
const value = await fleetRequest<{ items: Vehicle[] }>();
setItems(value.items); setError(""); return value.items;
}, []);
useEffect(() => {
let active = true;
let fallback: ReturnType<typeof setInterval> | undefined;
const read = async () => {
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
};
void read();
const events = new EventSource("/api/v1/fleet/events");
events.onmessage = event => {
if (!active) return;
try { const value = JSON.parse(event.data); setItems(value.items); setError(""); if (fallback) { clearInterval(fallback); fallback = undefined; } }
catch { unavailable(); }
};
const unavailable = () => {
if (!active) return;
setError("Связь обновляется. Показаны последние полученные сведения.");
if (!fallback) fallback = setInterval(() => void read(), 5000);
};
events.onerror = unavailable;
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
}, []);
return { items, error, refresh };
}
@@ -0,0 +1,79 @@
import {useCallback,useEffect,useRef,useState} from 'react';
import type {Demand} from './roverInput';
export interface RoverReading {
id:string;uuid:string;slot:string|null;label:string;age_ms:number;
values:{motor_current_a:number;input_current_a:number;input_voltage_v:number;erpm:number;duty:number;mos_temperature_c:number;fault_code:number};
}
export interface RoverState {
fresh:boolean;controlling:boolean;
snapshot:{supported?:boolean;instance?:string;state?:string;session_id?:string|null;message?:string|null;devices?:RoverReading[];
profile?:{layout:null|'1x1'|'2x2';bindings:Record<string,{device_id:string;uuid:string}>}};
}
const empty:RoverState={fresh:false,controlling:false,snapshot:{}};
async function request<T>(url:string,body?:unknown,keepalive=false):Promise<T>{
const response=await fetch(url,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:undefined,
body:body?JSON.stringify(body):undefined,cache:'no-store',signal:AbortSignal.timeout(body?350:1000),keepalive});
const result=await response.json();
if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Канал управления недоступен.');
return result;
}
export function useRoverControl(vehicleID:string,visible:boolean){
const url=`/api/v1/fleet/${encodeURIComponent(vehicleID)}/rover`;
const [state,setState]=useState<RoverState>(empty),[armed,setArmed]=useState(false),[pending,setPending]=useState(false),[error,setError]=useState<string|null>(null);
const [connecting,setConnecting]=useState(true),armPending=useRef(false);
const session=useRef<string|null>(null),seq=useRef(0),demand=useRef<Demand>({left:0,right:0}),sending=useRef(false),generation=useRef(0);
const inputGuard=useRef<(()=>boolean)|null>(null);
const setInputGuard=useCallback((guard:(()=>boolean)|null)=>{inputGuard.current=guard;},[]);
const stop=useCallback(()=>{
generation.current++;armPending.current=false;const id=session.current;session.current=null;demand.current={left:0,right:0};setArmed(false);setPending(false);
if(id)void request(url+'/command',{session_id:id,sequence:++seq.current,left:0,right:0,stop:true},true).catch(()=>{});
},[url]);
const send=useCallback(async()=>{
const id=session.current;if(!id)return;
// Check on every heartbeat, even when a previous request is still pending.
const inputValid=(demand.current.left===0&&demand.current.right===0)||inputGuard.current?.();
if(document.hidden||!document.hasFocus()||!inputValid)demand.current={left:0,right:0};
if(sending.current)return;
sending.current=true;
try{await request(url+'/command',{session_id:id,sequence:++seq.current,...demand.current,stop:false});}
catch(e){if(session.current===id){stop();setError(e instanceof Error?e.message:'Команда не подтверждена.');}}
finally{sending.current=false;}
},[url,stop]);
const setDemand=useCallback((next:Demand)=>{demand.current=next;void send();},[send]);
const pauseInput=useCallback(()=>{demand.current={left:0,right:0};void send();},[send]);
const arm=useCallback(async(currentA:number,maxErpm:number)=>{
if(session.current||armPending.current)return false;const g=++generation.current;armPending.current=true;setError(null);setPending(true);
try{
const result=await request<{session_id:string}>(url+'/arm',{standstill_confirmed:true,current_a:currentA,max_erpm:maxErpm});
if(g!==generation.current){void request(url+'/command',{session_id:result.session_id,sequence:1,left:0,right:0,stop:true},true).catch(()=>{});return false;}
// Retire every read started before this acknowledgement, including reads
// begun while POST /arm was pending. They describe the previous authority.
generation.current++;armPending.current=false;setPending(false);
seq.current=0;demand.current={left:0,right:0};session.current=result.session_id;setArmed(true);void send();
return true;
}catch(e){if(g===generation.current)setError(e instanceof Error?e.message:'Не удалось включить управление.');return false;}
finally{if(g===generation.current){armPending.current=false;setPending(false);}}
},[url,send]);
useEffect(()=>{
if(!visible){stop();setState(empty);setConnecting(false);return;}
let alive=true,busy=false,lastFault='';const started=performance.now();setConnecting(true);
const poll=async()=>{
if(busy)return;busy=true;const before=generation.current;
try{const value=await request<RoverState>(url);if(alive&&before===generation.current){setState(value);
setConnecting(!value.fresh&&performance.now()-started<2000);
if(session.current&&(!value.fresh||!value.controlling)){stop();}
const fault=value.snapshot.state==='fault'?value.snapshot.message??'':'';
if(fault&&lastFault!==fault){lastFault=fault;setError(fault);}
}}catch{if(alive&&before===generation.current){setState(empty);setConnecting(false);if(session.current)stop();}}finally{busy=false;}
};
void poll();const timer=setInterval(()=>void poll(),200),heartbeat=setInterval(()=>void send(),100);
const blur=()=>pauseInput(),hidden=()=>{if(document.hidden)pauseInput();};
window.addEventListener('blur',blur);window.addEventListener('pagehide',stop);document.addEventListener('visibilitychange',hidden);
return()=>{alive=false;clearInterval(timer);clearInterval(heartbeat);window.removeEventListener('blur',blur);window.removeEventListener('pagehide',stop);document.removeEventListener('visibilitychange',hidden);stop();};
},[url,visible,stop,send,pauseInput]);
const ready=armed&&state.fresh&&state.snapshot.session_id===session.current&&['ready','driving'].includes(state.snapshot.state??'');
const canArm=state.fresh&&state.snapshot.supported===true&&!state.controlling&&!['preparing','ready','driving','stopping'].includes(state.snapshot.state??'');
return {state,armed,pending,error,connecting,canArm,arm,stop,pauseInput,setDemand,setInputGuard,ready,clearError:()=>setError(null)};
}
export type RoverController=ReturnType<typeof useRoverControl>;
@@ -23,26 +23,34 @@ export async function resolveCanonicalLabReplay(
origin = window.location.origin, origin = window.location.origin,
signal, signal,
fetcher = globalThis.fetch, fetcher = globalThis.fetch,
sourceKind = "legacy-vegetation",
}: { }: {
origin?: string; origin?: string;
signal?: AbortSignal; signal?: AbortSignal;
fetcher?: typeof globalThis.fetch; fetcher?: typeof globalThis.fetch;
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
} = {}, } = {},
): Promise<CanonicalLabReplayDescriptor> { ): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin); const base = new URL(origin);
if ( if (
!SAFE_RESULT_ID.test(resultId) !(sourceKind === "portable-tgs"
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-semantic"
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-objects"
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
: SAFE_RESULT_ID.test(resultId))
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl) || !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}` || launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256) || !/^[a-f0-9]{64}$/.test(launch.sha256)
) { ) {
throw new Error("Канонический replay LAB имеет небезопасный descriptor."); throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
} }
const sourceUrl = const sourceUrl = sourceKind !== "legacy-vegetation"
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}` ? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
+ "/canonical-replay.rrd"; : `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`); const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
descriptorUrl.searchParams.set("base_generation", launch.sha256); if (sourceKind === "legacy-vegetation") descriptorUrl.searchParams.set("base_generation", launch.sha256);
if (descriptorUrl.origin !== base.origin) { if (descriptorUrl.origin !== base.origin) {
throw new Error("Канонический replay LAB должен быть same-origin."); throw new Error("Канонический replay LAB должен быть same-origin.");
} }
@@ -74,3 +82,53 @@ export async function resolveCanonicalLabReplay(
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"), blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
}; };
} }
export async function resolveAICompositionReplay(
runId: string,
launch: ObservationSessionReplayLaunch,
{
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
}
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
const response = await fetcher(descriptorUrl.href, {
method: "HEAD",
credentials: "same-origin",
headers: { Accept: "application/vnd.rerun.rrd" },
signal,
});
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
const byteLength = Number(response.headers.get("Content-Length"));
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
if (
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
) {
throw new Error("Составной replay LAB не прошёл проверку.");
}
return {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
byteLength,
sha256,
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
@@ -0,0 +1,74 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { plannerBase, plannerRequest, type Draft } from './planner';
export interface PlanningLiveTest {
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
tracking_state?: 'acquiring'|'tracking'|'lost';
tracking_established?: boolean;
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
route_relocalization_policy?: {version?:string;query_radius_m?:number};
}
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
const endpoint = plannerBase+'/live-tests';
const terminal = new Set(['completed','cancelled','error','interrupted']);
const Context = createContext<{
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
}|null>(null);
/** Server owns the run and its frozen reference, never the workspace launch profile. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
const [pollError,setPollError]=useState<string|null>(null);
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
const generation=useRef(0);
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
const select=useCallback(async(id:string)=>{
generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
finally{generation.current+=1;setBusy(false);}
},[]);
useEffect(()=>{
let disposed=false, timer:ReturnType<typeof setTimeout>;
const poll=async()=>{
const expected=generation.current;
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
if(!disposed)timer=setTimeout(()=>void poll(),1500);
};void poll();return()=>{disposed=true;clearTimeout(timer);};
},[]);
const begin=useCallback(async(draft:Draft)=>{
generation.current+=1;setBusy(true);setError(null);
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
finally{generation.current+=1;setBusy(false);}
},[]);
const finish=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const retryInitialization=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
}
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
/** A prepared consumer owns the next capture until explicitly finished or used. */
export function planningAwaitsCapture(test:PlanningLiveTest|null){
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
}
@@ -0,0 +1,44 @@
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
export type Direction = "forward" | "reverse";
export interface Draft {
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
zone: { session_id: string; generation: string; label: string };
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
}
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
export const plannerBase = "/api/v1/mission-planner";
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
}
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
return response.json() as Promise<T>;
}
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
const poses = source.poses.slice(start, end + 1);
return direction === "reverse" ? poses.reverse() : poses;
}
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
export function endAtDistance(source: PlanningSource, start: number, length: number) {
const target = source.poses[start].distance_m + length;
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
return index < 0 ? source.poses.length - 1 : index;
}
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
return data;
}
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
let low = min, high = max;
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
}
@@ -0,0 +1,51 @@
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
export interface PlanningMatchState {
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
result:{status:string}|null;
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
presentation_state?:'live'|'historical'|'unlocalized';
}
/** Current geometry is insufficient: the temporal gate must also be tracking. */
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
&&(t.presentation_state===undefined||t.presentation_state==='live')
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
&&t.result?.status==='candidate';
}
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
type PlanningPresentation=PlanningMatchState & {message:string};
const phaseLabels:Record<string,string>={
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
};
/** Terminal failures and data freshness take precedence over successful old fits. */
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
if(t.planning_phase==='lost'&&!t.tracking_established)
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
if(t.planning_phase&&phaseLabels[t.planning_phase])
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
}
/** A plugin may show this only for its matching, authoritative acquiring session. */
export function planningActivity(
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
):SpatialActivityPresentation|undefined{
if(!t.query_session_id||!['running','error'].includes(t.state)
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
const status=planningStatus(t,error);
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
}
@@ -0,0 +1,93 @@
import type { PlanningSceneDelivery } from './planningSceneStream';
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
'missioncore.planning-browser-presentation/v1';
type BrowserPresentationSample = {
cloud_revision:number;
cloud_sequence:number;
display_epoch:string;
request_ms:number;
rerun_admission_ms:number;
first_animation_frame_ms:number|null;
second_animation_frame_ms:number|null;
frame_timeout:boolean;
source_to_second_animation_frame_upper_bound_ms:number|null;
};
type ReporterDependencies = {
url:string;
fetcher?:typeof fetch;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
const batchSize=8;
const flushDelayMs=250;
/**
* Bounded, best-effort browser observation. These samples never drive scene
* admission, registration, navigation, or device control.
*/
export function createPlanningPresentationReporter(deps:ReporterDependencies){
const fetcher=deps.fetcher??fetch;
const schedule=deps.schedule??setTimeout;
const cancel=deps.cancel??clearTimeout;
let pending:BrowserPresentationSample[]=[];
let timer:ReturnType<typeof setTimeout>|undefined;
let sending=false,stopped=false;
const flush=async()=>{
if(sending||stopped||!pending.length)return;
if(timer!==undefined){cancel(timer);timer=undefined;}
sending=true;
const samples=pending.splice(0,batchSize);
try{
// Deliberately best-effort: the presentation observer must not make the
// live Rerun channel wait for an unrelated report request.
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
headers:{'Content-Type':'application/json'},
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
});
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
}catch{
// A lost browser report is observable as fewer samples, never retried into
// an unbounded queue and never promoted into a capture/fit failure.
}finally{
sending=false;
if(!stopped&&pending.length)void flush();
}
};
const record=(delivery:PlanningSceneDelivery,timing:{
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
secondAnimationFrameMs:number|null;
})=>{
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
const second=timing.secondAnimationFrameMs;
pending.push({
cloud_revision:delivery.cloudRevision,
cloud_sequence:delivery.cloudSequence,
display_epoch:delivery.displayEpoch,
request_ms:delivery.requestMs,
rerun_admission_ms:timing.rerunAdmissionMs,
first_animation_frame_ms:timing.firstAnimationFrameMs,
second_animation_frame_ms:second,
frame_timeout:second===null,
source_to_second_animation_frame_upper_bound_ms:second===null?null:
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
});
if(pending.length>=batchSize)void flush();
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
};
return {
record,
dispose:()=>{
stopped=true;
if(timer!==undefined)cancel(timer);
pending=[];
},
};
}
@@ -0,0 +1,37 @@
import type { Draft } from './planner';
import type { RegistrationReport } from './useRegistrationTest';
import { plannerBase, plannerRequest } from './planner';
export interface PlanningProject {
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
state: string; created_at_utc: string; result_status: string|null;
reference_label: string; query_label: string|null; draft_id: string; revision: number;
}
export interface PlanningProjectDetail extends PlanningProject {
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
scene_note?:string|null;
}
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
export async function deletePlanningProject(project: PlanningProject) {
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
);
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
}
export function planningProjectStatus(p: PlanningProject) {
if (p.kind === 'draft') return 'Подготовка';
if (planningProjectPending(p)) return 'Выполняется';
if (p.result_status === 'candidate') return 'Кандидат совмещения';
if (p.result_status === 'rejected') return 'Совмещение отклонено';
return 'Без результата совмещения';
}
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
return items.find(p => p.key === last)?.key
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
?? items[0]?.key ?? '';
}
@@ -0,0 +1,87 @@
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
export type PlanningSceneDelivery = {
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
};
type Dependencies = {
url:string; snapshot:()=>Snapshot;
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
fetcher?:typeof fetch; now?:()=>number;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
export function startPlanningSceneStream(deps:Dependencies){
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
let timer:ReturnType<typeof setTimeout>|undefined;
let request:AbortController|undefined;
const poll=async()=>{
if(stopped)return;
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
timer=schedule(()=>void poll(),500);return;
}
request=new AbortController();
const deadline=schedule(()=>request?.abort(),2500);
let failed=false;
try{
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
params.set('base',String(needsBase||!cursor||key!==nextKey));
if(cursor)params.set('cursor',cursor);
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
if(!response.ok)throw new Error('scene unavailable');
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
if(stopped)return;
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
const elapsed=now()-started;
if(presentation==='live'){
// Include the entire request duration: a conservative upper bound, without
// comparing clocks on different hosts. Late bytes must not revive green.
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
throw new Error('scene expired during delivery');
}
const latest=deps.snapshot();
// Never apply a response from the previous display mode or live/ended state.
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
if(!nextCursor)throw new Error('scene cursor missing');
if(bytes)await deps.apply(bytes,{
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
}); // Native admission only; the caller may record a separate presentation proxy.
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
}catch{
// Keep the last admitted cursor (including camera intent). A full geometry
// repair after a missed/expired response is not permission to reset the eye.
if(!stopped){failed=true;needsBase=true;deps.error();}
}finally{
cancel(deadline);request=undefined;
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
}
};
void poll();
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
}
function parseNonnegativeInteger(value:string|null){
if(value===null||!/^\d+$/.test(value))return null;
const parsed=Number(value);
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
}
function parseFiniteNumber(value:string|null){
if(value===null||value.trim()==='')return null;
const parsed=Number(value);
return Number.isFinite(parsed)?parsed:null;
}
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
export function useMissionPlanner() {
const [sessions, setSessions] = useState<SessionOption[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Draft[]>([]);
const [saved, setSaved] = useState<Draft | null>(null);
const [name, setName] = useState("");
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [direction, setDirection] = useState<Direction>("forward");
const [error, setError] = useState<string | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [catalogBusy, setCatalogBusy] = useState(true);
const [loadVersion, setLoadVersion] = useState(0);
const [sourceVersion, setSourceVersion] = useState(0);
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
const [check, setCheck] = useState<RouteCheck | null>(null);
useEffect(() => {
const abort = new AbortController(); setCatalogBusy(true); setError(null);
void Promise.all([
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
return () => abort.abort();
}, [loadVersion]);
useEffect(() => {
const abort = new AbortController(); setSource(null); setSourceError(null);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
return () => abort.abort();
}, [sessionId, sourceVersion, pinnedGeneration]);
const currentSource = source?.session_id === sessionId ? source : null;
// New executions always own the complete reference. No independent interval
// state can survive a source change or collapse on same-source reselection.
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
const chooseSource = (id: string) => { setPinnedGeneration(null); setSessionId(id); setSourceVersion(n => n + 1); setCheck(null); };
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
const openDraft = async (id: string) => {
setBusy(true); setError(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
setDirection(next.route.direction); setCheck(null);
setSourceVersion(n => n + 1);
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const save = async () => {
if (!ready || !currentSource) return;
setBusy(true); setError(null); setCheck(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
generation: currentSource.generation, whole_recording: true, direction,
}) });
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
return next;
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const runCheck = async () => {
if (!saved || dirty || !ready) return;
setBusy(true); setError(null); setCheck(null);
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const loadMore = async () => {
if (!cursor) return;
setCatalogBusy(true);
try {
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
};
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
}
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { plannerBase, plannerRequest } from './planner';
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
const storageKey = 'missioncore.planning-project.selected.v1';
export function usePlanningProjects() {
const [items, setItems] = useState<PlanningProject[]>([]);
const [key, setKey] = useState('');
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
const [version, setVersion] = useState(0);
const initialized = useRef(false);
const selectedKey = useRef('');
const removedKeys = useRef(new Set<string>());
const removing = useRef(false);
const select = useCallback((value: string) => {
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
}, []);
const refresh = useCallback(() => setVersion(n=>n+1), []);
const remove = useCallback(async (project: PlanningProject) => {
if (removing.current) throw new Error('Удаление уже выполняется.');
removing.current = true;
try {
await deletePlanningProject(project);
removedKeys.current.add(project.key);
setItems(items => items.filter(item => item.key !== project.key));
if (selectedKey.current === project.key) select('');
} finally { removing.current = false; }
}, [select]);
useEffect(()=>{
const abort = new AbortController(); setLoading(true); setError(null);
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
if (abort.signal.aborted) return;
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
setItems(visible);
if (!initialized.current) {
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
select(defaultPlanningProject(visible,last));
}
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
return ()=>abort.abort();
},[version,select]);
useEffect(()=>{
if (!key) return;
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
setDetail(null); setError(null);
const [kind,id]=key.split(':');
const poll=async()=>{
try {
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
if(abort.signal.aborted || removedKeys.current.has(key))return;
if(next.key!==key)throw new Error('Получен результат другого исследования.');
setDetail(next);setError(null);
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
};
void poll(); return()=>{abort.abort();clearTimeout(timer);};
},[key,version]);
return {items,key,detail,loading,error,select,refresh,remove};
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
export interface RegistrationReport {
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
}
export function useRegistrationTest(draft: Draft | null) {
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
const [catalogBusy,setCatalogBusy]=useState(false);
// Repeat recordings belong to the planner, including its own captured passes;
// they are not restricted to the independent-reference catalog.
useEffect(()=>{
const abort=new AbortController();setCatalogBusy(true);
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
return()=>abort.abort();
},[]);
const loadMore=async()=>{
if(!cursor||catalogBusy)return;setCatalogBusy(true);
try{
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
};
useEffect(() => {
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [sessionId]);
const busy = submitting;
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
const run = async (savedDraft = draft) => {
if (!source || busy || !savedDraft) return;
setSubmitting(true); setError(null);
try {
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
});
return data;
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
};
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
}
@@ -0,0 +1,53 @@
/** Small lease for the server's ephemeral blueprint, never for the recording. */
export function keepRecordedBlueprintSession({
endpointUrl,
origin,
applicationId,
recordingId,
ownerId,
fetcher = fetch,
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
cancel = (handle: number) => window.clearInterval(handle),
}: {
endpointUrl: string;
origin: string;
applicationId: string;
recordingId: string;
ownerId: string;
fetcher?: typeof fetch;
schedule?: (callback: () => void) => number;
cancel?: (handle: number) => void;
}): () => void {
const endpoint = new URL(endpointUrl, origin);
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
}
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
const identity = { application_id: applicationId, recording_id: recordingId,
blueprint_session_id: ownerId };
let closed = false;
let pending: AbortController | null = null;
const renew = () => {
if (closed) return;
pending?.abort();
pending = new AbortController();
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
};
const timer = schedule(renew);
return () => {
if (closed) return;
closed = true;
cancel(timer);
pending?.abort();
pending = null;
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "release" }),
keepalive: true, signal: AbortSignal.timeout(5_000),
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
};
}
@@ -0,0 +1,68 @@
import type {SceneSettings} from '../../sceneSettings';
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
export function pointDisplayKey(settings: PointDisplaySettings): string {
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
}
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
export async function streamRecordedPointDisplay(
blueprintUrl: string, settings: PointDisplaySettings,
identity: {applicationId: string; recordingId: string}, displayBank: string,
signal: AbortSignal, send: (chunk: Uint8Array) => void,
fetcher: typeof fetch = fetch,
sourceGeneration?: string,
): Promise<number> {
const endpoint = new URL(blueprintUrl, window.location.origin);
const percent = settings.pointDecimationPercent ?? 0;
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
point_decimation_percent: percent, display_bank: displayBank,
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
throw new Error('Не удалось подготовить прореженное облако');
}
const reader = response.body.getReader();
let bytes = 0;
let pending = new Uint8Array(0);
let admitted = false;
let complete = false;
try {
while (true) {
const {done, value} = await reader.read();
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
if (done) break;
bytes += value.byteLength;
const combined = new Uint8Array(pending.length + value.length);
combined.set(pending); combined.set(value, pending.length); pending = combined;
if (!admitted) {
if (pending.length < 4) continue;
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
pending = pending.slice(4); admitted = true;
}
while (pending.length >= 4 && !complete) {
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
if (length === 0) { pending = pending.slice(4); complete = true; break; }
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
if (pending.length < length + 4) break;
const payload = pending.slice(4, length + 4);
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
send(payload); pending = pending.slice(length + 4);
}
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
}
if (!complete || pending.length) throw new Error('Поток облака не завершён');
return bytes;
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
@@ -23,7 +23,7 @@ export type ObservationSessionStatus =
| "interrupted" | "interrupted"
| "failed"; | "failed";
export type ObservationSessionScope = "all" | "source" | "laboratory"; export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
export interface ObservationLabInstance { export interface ObservationLabInstance {
labId: string; labId: string;
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
seekable: true; seekable: true;
byteLength: number; byteLength: number;
sha256: string; sha256: string;
mapGeneration?: string;
playback: { playback: {
speed: number; speed: number;
loop: boolean; loop: boolean;
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
"seekable", "seekable",
"byte_length", "byte_length",
"sha256", "sha256",
"map_generation",
"playback", "playback",
"media_sources", "media_sources",
]); ]);
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) { if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256."); throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
} }
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
}
const viewerSourceUrl = requireString( const viewerSourceUrl = requireString(
launch.viewer_source_url, launch.viewer_source_url,
"launch.viewer_source_url", "launch.viewer_source_url",
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
integer: true, integer: true,
}), }),
sha256: launch.sha256, sha256: launch.sha256,
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
playback: { speed, loop: launch.playback.loop }, playback: { speed, loop: launch.playback.loop },
mediaSources, mediaSources,
}; };
@@ -0,0 +1,72 @@
import {
decodeObservationSessionCatalog,
ObservationSessionApiError,
ObservationSessionContractError,
type ObservationSessionCatalog,
type ObservationSessionFetch,
type ObservationSessionScope,
} from "./sessionArchive";
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
readonly nextCursor: string | null;
}
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
export async function fetchObservationSessionCatalogPage({
scope,
limit = 100,
cursor = null,
signal,
fetcher = globalThis.fetch,
}: {
scope: ObservationSessionScope;
limit?: number;
cursor?: string | null;
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
}): Promise<ObservationSessionCatalogPage> {
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|| (cursor !== null && !CURSOR.test(cursor))) {
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
}
signal?.throwIfAborted();
const query = new URLSearchParams({ limit: String(limit), scope });
if (scope === "laboratory") query.set("lab_contract", "v3");
query.set("pagination", "cursor-v1");
if (cursor !== null) query.set("cursor", cursor);
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
method: "GET", headers: { Accept: "application/json" }, signal,
});
if (!response.ok) {
throw new ObservationSessionApiError(
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
}
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
}
const page = body as Record<string, unknown>;
if (Object.keys(page).length !== 3
|| page.schema_version !== PAGE_SCHEMA
|| !Object.hasOwn(page, "items")
|| !(page.next_cursor === null
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
}
const catalog = decodeObservationSessionCatalog({ items: page.items });
if (catalog.items.length > limit) {
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
}
signal?.throwIfAborted();
return { ...catalog, nextCursor: page.next_cursor };
}
@@ -0,0 +1,54 @@
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
const schema = 'missioncore.session-display-profile/v1';
const fields = {
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
} as const;
function endpoint(sessionId: string) {
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
}
export function encodeSessionDisplay(settings: SceneSettings) {
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
}
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
}
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
}
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
throw new Error('Некорректные настройки отображения');
}
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
}
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
const document = await response.json();
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
: decodeSessionDisplay(document, sessionId);
}
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
decodeSessionDisplay(await response.json(), sessionId);
}
@@ -0,0 +1,41 @@
export interface SessionOverviewMetrics {
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
interval_statistics_complete: boolean; gaps_over_second: number | null;
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
spatial_available: boolean;
}
export interface SessionOverview {
schema_version: "missioncore.session-overview/v1";
state: "queued" | "preparing" | "ready" | "error";
session: { session_id: string; display_name: string; duration_seconds: number | null;
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
metrics?: SessionOverviewMetrics | null;
scene_url?: string | null;
message?: string;
messages_processed?: number;
}
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
signal, method: retry ? "POST" : "GET", cache: "no-store",
});
if (!response.ok) throw new Error("Обзор записи недоступен.");
const data = await response.json() as SessionOverview;
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
if (data.scene_url) {
const url = new URL(data.scene_url, window.location.origin);
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
}
return data;
}
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
const xmax = Math.max(1, ...valid.map(p => p[0]));
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
}
@@ -0,0 +1,38 @@
export type OverviewViewMode = "3d" | "top";
export type OverviewRepresentation = "original" | "corrected";
export interface OverviewComparison {
generation: string; map_generation: string; sample_points: number; source_points: number;
original_path_m: number; corrected_path_m: number; height_min_m: number; height_max_m: number;
}
export interface OverviewSpatialMetadata {
default_representation?: OverviewRepresentation;
height_min_m: number | null; height_max_m: number | null; sample_points: number;
comparison?: OverviewComparison;
}
function endpoint(source: string) {
const url = new URL(source, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
return url;
}
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
if (!response.ok) throw new Error("Параметры среза недоступны.");
return response.json();
}
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal,
comparisonGeneration: string | null = null, representation: OverviewRepresentation = "original") {
if (representation === "corrected" && !comparisonGeneration) throw new Error("Исправленная версия недоступна.");
const url = endpoint(source);
const generation = url.searchParams.get("generation");
url.search = "";
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
comparison_generation: comparisonGeneration, representation }) });
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
}
@@ -0,0 +1,38 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
const [error, setError] = useState<string | null>(null);
const applyRef = useRef(apply); applyRef.current = apply;
const current = useRef(sessionId); current.current = sessionId;
const writeTail = useRef(Promise.resolve());
const revision = useRef(0);
useEffect(() => {
setError(null);
if (!sessionId) return;
const abort = new AbortController();
const readRevision = revision.current;
applyRef.current({...defaultSceneSettings});
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
if (abort.signal.aborted || current.current !== sessionId) return;
if (revision.current === readRevision) applyRef.current(settings);
}).catch(reason => {
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
});
return () => abort.abort();
}, [sessionId]);
const edited = useCallback(() => { revision.current += 1; }, []);
const save = useCallback((settings: SceneSettings) => {
const id = current.current;
if (!id) return;
const snapshot = {...settings};
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
if (current.current === id) setError(null);
}).catch(reason => {
if (current.current === id) setError(String(reason.message ?? reason));
});
}, []);
return {save, edited, error, dismissError: () => setError(null)};
}
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
export function useSessionOverview(sessionId: string) {
const [data, setData] = useState<SessionOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [retryGeneration, setRetryGeneration] = useState(0);
useEffect(() => {
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
setData(null); setError(null);
const load = async (retry = false) => {
try {
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
if (abort.signal.aborted) return;
setData(next);
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
} catch (reason) {
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
}
};
void load(retryGeneration > 0);
return () => { abort.abort(); clearTimeout(timer); };
}, [sessionId, retryGeneration]);
return { data: data?.session.session_id === sessionId ? data : null, error,
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
}
export function useSessionOverviewMode(enabled: boolean) {
const [open, setOpen] = useState(false);
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
useEffect(() => {
if (!open) return;
const close = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
};
window.addEventListener("keydown", close, true);
return () => window.removeEventListener("keydown", close, true);
}, [open]);
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
}
@@ -24,9 +24,11 @@ export interface RerunPlaybackController {
export interface RecordedPerceptionLayers { export interface RecordedPerceptionLayers {
enabled: boolean; enabled: boolean;
cameraImage?: boolean;
detections2d: boolean; detections2d: boolean;
segmentation: boolean; segmentation: boolean;
cuboids3d: boolean; cuboids3d: boolean;
costmap?: boolean;
} }
export interface RecordedRrdArtifactDescriptor { export interface RecordedRrdArtifactDescriptor {
@@ -75,12 +77,21 @@ export interface RecordedSessionRerunProfile {
semanticLayer?: "city" | "vegetation"; semanticLayer?: "city" | "vegetation";
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */ /** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
unifiedPerception?: boolean; unifiedPerception?: boolean;
/** Controlled camera-column share for the native horizontal Rerun container. */
unifiedCameraShare?: number;
/** Requests the canonical top-down eye without changing the world coordinate frame. */ /** Requests the canonical top-down eye without changing the world coordinate frame. */
planView?: boolean; planView?: boolean;
perceptionRetryGeneration: number; perceptionRetryGeneration: number;
lockPerceptionCameraInteraction: boolean; 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 * 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 * been republished as a native Rerun sidecar. It must never be selected by a
@@ -97,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile {
export type RerunViewerProfile = export type RerunViewerProfile =
| LiveAcquisitionRerunProfile | LiveAcquisitionRerunProfile
| RecordedSessionRerunProfile; | RecordedSessionRerunProfile
| LaboratoryResultRerunProfile;
export type ObservationViewerProfile = export type ObservationViewerProfile =
| RerunViewerProfile | RerunViewerProfile
@@ -115,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
export function liveAcquisitionRerunProfile( export function liveAcquisitionRerunProfile(
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">, input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
): LiveAcquisitionRerunProfile { ): LiveAcquisitionRerunProfile {
return { kind: "live-acquisition", clock: "stream_time", ...input }; return { ...input, kind: "live-acquisition", clock: "stream_time" };
} }
export function recordedSessionRerunProfile( export function recordedSessionRerunProfile(
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">, input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
): RecordedSessionRerunProfile { ): 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" };
} }

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