diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index cdd034e352474391a09d65363e6167e36a87f0e1..17df0eefbd794fc7d7f3c8ea9c2d3294433728c6 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -16,7 +16,18 @@ jobs: with: fetch-depth: 0 lfs: true + ref: main - name: Push to hub env: HF_TOKEN: ${{ secrets.HF_TOKEN }} - run: git push https://mishig:$HF_TOKEN@huggingface.co/spaces/lerobot/visualize_dataset main -f + run: | + set -euo pipefail + + git lfs install --local + + git remote remove hf 2>/dev/null || true + git remote add hf "https://mishig:${HF_TOKEN}@huggingface.co/spaces/lerobot/visualize_dataset" + + # Push large files first, then refs. + git lfs push hf main + git push hf main -f diff --git a/.gitignore b/.gitignore index 31d699f33c1cf4620ae25bf88f67ef2b23cf054c..92933063ec67b7e1409c45e271dfcc0263b45e9b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +package-lock.json /.pnp .pnp.* .yarn/* @@ -16,7 +17,8 @@ # next.js /.next/ /out/ -/public +/public/* +!/public/urdf/ # production /build diff --git a/README.md b/README.md index 13a24502ea7b5d81d3433cc7d2b8a59767fe856b..4eac83ccc6ed611a13884836b786c484a950b969 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ license: apache-2.0 # LeRobot Dataset Visualizer -LeRobot Dataset Visualizer is a web application for interactive exploration and visualization of robotics datasets, particularly those in the LeRobot format. It enables users to browse, view, and analyze episodes from large-scale robotics datasets, combining synchronized video playback with rich, interactive data graphs. +LeRobot Dataset Tool and Visualizer is a web application for interactive exploration and visualization of robotics datasets, particularly those in the LeRobot format. It enables users to browse, view, and analyze episodes from large-scale robotics datasets, combining synchronized video playback with rich, interactive data graphs. ## Project Overview @@ -20,13 +20,20 @@ This tool is designed to help robotics researchers and practitioners quickly ins - Navigating between organizations, datasets, and episodes - Watching episode videos - Exploring synchronized time-series data with interactive charts +- Analyzing action quality and identifying problematic episodes +- Visualizing robot poses in 3D using URDF models - Paginating through large datasets efficiently ## Key Features - **Dataset & Episode Navigation:** Quickly jump between organizations, datasets, and episodes using a sidebar and navigation controls. - **Synchronized Video & Data:** Video playback is synchronized with interactive data graphs for detailed inspection of sensor and control signals. -- **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination and chunking. +- **Overview Panel:** At-a-glance summary of dataset metadata, camera info, and episode details. +- **Statistics Panel:** Dataset-level statistics including episode count, total recording time, frames-per-second, and an episode-length histogram. +- **Action Insights Panel:** Data-driven analysis tools to guide training configuration — includes autocorrelation, state-action alignment, speed distribution, and cross-episode variance heatmap. +- **Filtering Panel:** Identify and flag problematic episodes (low movement, jerky motion, outlier length) for removal. Exports flagged episode IDs as a ready-to-run LeRobot CLI command. +- **3D URDF Viewer:** Visualize robot joint poses frame-by-frame in an interactive 3D scene, with end-effector trail rendering. Supports SO-100, SO-101, and OpenArm bimanual robots. +- **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination, chunking, and lazy-loaded panels for fast initial load. - **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience. ## Technologies Used @@ -34,6 +41,8 @@ This tool is designed to help robotics researchers and practitioners quickly ins - **Next.js** (App Router) - **React** - **Recharts** (for data visualization) +- **Three.js** + **@react-three/fiber** + **@react-three/drei** (for 3D URDF visualization) +- **urdf-loader** (for parsing URDF robot models) - **hyparquet** (for reading Parquet files) - **Tailwind CSS** (styling) diff --git a/bun.lock b/bun.lock index 8d9c465bff51dbe19cb39e43d6b1e6eaedaf933e..38981bf46b23bee35b21b167e6713ed675b3bdc3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,17 @@ "": { "name": "lerobot-viewer", "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@types/three": "^0.182.0", "hyparquet": "^1.12.1", "next": "15.3.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", "recharts": "^2.15.3", + "three": "^0.182.0", + "urdf-loader": "^0.12.6", }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -31,6 +36,8 @@ "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -123,6 +130,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], + + "@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@next/env": ["@next/env@15.3.6", "", {}, "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw=="], @@ -153,6 +164,10 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="], + + "@react-three/fiber": ["@react-three/fiber@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="], @@ -191,6 +206,8 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -211,6 +228,8 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -219,10 +238,20 @@ "@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], + "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-reconciler": ["@types/react-reconciler@0.28.9", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg=="], + + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="], + + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.55.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw=="], @@ -281,6 +310,12 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], + + "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -321,10 +356,16 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -335,6 +376,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -349,6 +392,8 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -393,12 +438,16 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -471,6 +520,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -505,6 +556,8 @@ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "glsl-noise": ["glsl-noise@0.0.0", "", {}, "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -523,10 +576,16 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="], + "hyparquet": ["hyparquet@1.25.0", "", {}, "sha512-isJx+RplYT3aJc5yhaG5CeOZSBJecHZgYsUi7NE6P/nAbxxA0hZcyul0tUsWCQLc9QXYQ2uFyYBrk61JbJO0cg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -569,6 +628,8 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -593,6 +654,8 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "its-fine": ["its-fine@2.0.0", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -617,6 +680,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -649,12 +714,18 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="], + + "meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], @@ -711,10 +782,14 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -733,6 +808,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], @@ -741,6 +818,8 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -785,6 +864,10 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], + + "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], @@ -811,22 +894,38 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="], + + "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], + + "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="], + + "troika-three-utils": ["troika-three-utils@0.52.4", "", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="], + + "troika-worker-utils": ["troika-worker-utils@0.52.0", "", {}, "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -845,10 +944,20 @@ "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + "urdf-loader": ["urdf-loader@0.12.6", "", { "peerDependencies": { "three": ">=0.152.0" } }, "sha512-EwpgOCPe6Tep2+MXoo/r13keHaKQXMcM+4s9+jX0NRxNS/QSNuP5JPdk5AIgWEoEB43AkEj9Vk+Nr53NkXgSbA=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "webgl-constants": ["webgl-constants@1.1.1", "", {}, "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="], + + "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -863,6 +972,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -903,6 +1014,12 @@ "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], + + "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], + + "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], } } diff --git a/next.config.ts b/next.config.ts index bce2ed072ec99a4648934a8a4f4da818bcac82db..c82988760903ea62cdb26d92a0ee7906ce718a6d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,13 @@ import type { NextConfig } from "next"; import packageJson from "./package.json"; const nextConfig: NextConfig = { + typescript: { + ignoreBuildErrors: true, + }, + eslint: { + ignoreDuringBuilds: true, + }, + transpilePackages: ["three"], generateBuildId: () => packageJson.version, }; diff --git a/package.json b/package.json index 0108d5a9fca5d7ef69c0220f2254c9ae799d71ce..adb9de66a2f25dbf06dda9474282023a96c058ec 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,20 @@ "validate": "bun run type-check && bun run lint && bun run format:check" }, "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", "hyparquet": "^1.12.1", "next": "15.3.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", - "recharts": "^2.15.3" + "recharts": "^2.15.3", + "three": "^0.182.0", + "urdf-loader": "^0.12.6" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/three": "^0.182.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/public/urdf/openarm/assets/body_link0.dae b/public/urdf/openarm/assets/body_link0.dae new file mode 100644 index 0000000000000000000000000000000000000000..4a22b6975f5466ec0638ac21ff220f45a71c2dc4 --- /dev/null +++ b/public/urdf/openarm/assets/body_link0.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a05f59379dcf694e5ff336b1ee3a47ea504a2425a2fa774af1b14e096d9a239a +size 10783948 diff --git a/public/urdf/openarm/assets/body_link0.stl b/public/urdf/openarm/assets/body_link0.stl new file mode 100644 index 0000000000000000000000000000000000000000..ec9fc424c3a5e7a9bbe83ab12638424db5e0772f --- /dev/null +++ b/public/urdf/openarm/assets/body_link0.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ab0afa9a26bebbf5c6149894c41d96622caa649555ae7c5f0f2548f86148d91 +size 7955034 diff --git a/public/urdf/openarm/assets/finger.dae b/public/urdf/openarm/assets/finger.dae new file mode 100644 index 0000000000000000000000000000000000000000..d8b2fc720bcaf00ff05009fa0d00e941e2ef55d5 --- /dev/null +++ b/public/urdf/openarm/assets/finger.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64492f6a80a551a7a53e3f769facc42ee4a9bce1634ff63300a9d7f5b3628cb0 +size 6021838 diff --git a/public/urdf/openarm/assets/finger.stl b/public/urdf/openarm/assets/finger.stl new file mode 100644 index 0000000000000000000000000000000000000000..e3868451cd742a47f19b4f628f18b8367ba2cf63 --- /dev/null +++ b/public/urdf/openarm/assets/finger.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25c115c7c55a422f30ad11581dc21576dc8fc4e09e659890772d86fe82ec04d7 +size 432484 diff --git a/public/urdf/openarm/assets/hand.dae b/public/urdf/openarm/assets/hand.dae new file mode 100644 index 0000000000000000000000000000000000000000..7b364a78a3e67fc2003d0b1c21480ba3f0b43d54 --- /dev/null +++ b/public/urdf/openarm/assets/hand.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7a1ff18be1b603544f36645b50e9842ee1ea94d1ba8cbccdeded50946f4bce7 +size 1549205 diff --git a/public/urdf/openarm/assets/hand.stl b/public/urdf/openarm/assets/hand.stl new file mode 100644 index 0000000000000000000000000000000000000000..381740b7d326aa6b532c9c7effd04353e956d467 --- /dev/null +++ b/public/urdf/openarm/assets/hand.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e5d373ebbd3fd001b506058644062ad71a68f1ced5ca5d5ed0f6de20137956b +size 18284 diff --git a/public/urdf/openarm/assets/link0.dae b/public/urdf/openarm/assets/link0.dae new file mode 100644 index 0000000000000000000000000000000000000000..6b95a2aa13308323a14e32741bc77b7279e8a33d --- /dev/null +++ b/public/urdf/openarm/assets/link0.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29037f2e51d6b34dbbdbd1eecb557abd900aa1897be4d4a30a7114e649f987b9 +size 5356744 diff --git a/public/urdf/openarm/assets/link0.stl b/public/urdf/openarm/assets/link0.stl new file mode 100644 index 0000000000000000000000000000000000000000..ce7f518db5acea8eaf4b32d8d1a55600d9b99cec --- /dev/null +++ b/public/urdf/openarm/assets/link0.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c77bdc9419947088e1dfc452e29c6092cc7b02b239ff4f2f5be3d77e393af185 +size 4148234 diff --git a/public/urdf/openarm/assets/link1.dae b/public/urdf/openarm/assets/link1.dae new file mode 100644 index 0000000000000000000000000000000000000000..97898d0de8e5c3ce03e0d7de2f6c7efdd7511649 --- /dev/null +++ b/public/urdf/openarm/assets/link1.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c25937f901a4c15730927e08bf5fd26a072e801c529f0bd9678ba9758330647d +size 7655919 diff --git a/public/urdf/openarm/assets/link1.stl b/public/urdf/openarm/assets/link1.stl new file mode 100644 index 0000000000000000000000000000000000000000..41bee1e4f46c8ce9ad267a549352460b406fbbed --- /dev/null +++ b/public/urdf/openarm/assets/link1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a182c7fd4d25226aff6d9e2dd9d1008a85fce7df8e16525722a5c5053f8b055 +size 5741534 diff --git a/public/urdf/openarm/assets/link2.dae b/public/urdf/openarm/assets/link2.dae new file mode 100644 index 0000000000000000000000000000000000000000..1765606e0d0973af34ee8e19fc3eb5f70c845e59 --- /dev/null +++ b/public/urdf/openarm/assets/link2.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d2a1ec857a22eaa180034e2dcd989c54611abab6ce6659eb4999570c34cc124 +size 6288652 diff --git a/public/urdf/openarm/assets/link2.stl b/public/urdf/openarm/assets/link2.stl new file mode 100644 index 0000000000000000000000000000000000000000..beebd74d08edd5fb458b549da49277cabc412031 --- /dev/null +++ b/public/urdf/openarm/assets/link2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de1b190a56c16dea14546fb8e22c86eccabc2ca5e054819630c1932592381745 +size 4543534 diff --git a/public/urdf/openarm/assets/link3.dae b/public/urdf/openarm/assets/link3.dae new file mode 100644 index 0000000000000000000000000000000000000000..f91d58bf1c3804f94d05d92db882c631021efe86 --- /dev/null +++ b/public/urdf/openarm/assets/link3.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97d73c4bae3f3f7570bf3e28306da719aa99a6bec662060c9e358e5625b11cce +size 6846210 diff --git a/public/urdf/openarm/assets/link3.stl b/public/urdf/openarm/assets/link3.stl new file mode 100644 index 0000000000000000000000000000000000000000..7ca572291a0000fb23a9ebf5ac65a767aedf6936 --- /dev/null +++ b/public/urdf/openarm/assets/link3.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d47d75d0c47a65021708ba63cb73162bf7c3b1d14e9cfc70dfa47336034ac76 +size 4978834 diff --git a/public/urdf/openarm/assets/link4.dae b/public/urdf/openarm/assets/link4.dae new file mode 100644 index 0000000000000000000000000000000000000000..ed8c277d66dfcb60f37f685a1ec7d7bfa5a864d4 --- /dev/null +++ b/public/urdf/openarm/assets/link4.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59eb1cbf44b1250ad9533176b30084179f9164fcb998ee2623d89b48968ec426 +size 6644484 diff --git a/public/urdf/openarm/assets/link4.stl b/public/urdf/openarm/assets/link4.stl new file mode 100644 index 0000000000000000000000000000000000000000..a12a14ece3d3909cdff9a7819ebd4920371f67aa --- /dev/null +++ b/public/urdf/openarm/assets/link4.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ca8149f2ce8b1b102270ec0b1a4b75c3e6f98c09b084430a450adce808607e1 +size 4944684 diff --git a/public/urdf/openarm/assets/link5.dae b/public/urdf/openarm/assets/link5.dae new file mode 100644 index 0000000000000000000000000000000000000000..e60d3812246a356c526030993623a38866576d6b --- /dev/null +++ b/public/urdf/openarm/assets/link5.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96fd3516df16a772a84c268529e5b53b05e6df07da6ccfe850bc091eba947156 +size 9034473 diff --git a/public/urdf/openarm/assets/link5.stl b/public/urdf/openarm/assets/link5.stl new file mode 100644 index 0000000000000000000000000000000000000000..e1fac73ba56b307813ecbdcd9234a8ef867ee04e --- /dev/null +++ b/public/urdf/openarm/assets/link5.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30ea7abfdd3661b315f897bb82a5f34fd966357b358e890e155e928e931ea975 +size 6322984 diff --git a/public/urdf/openarm/assets/link6.dae b/public/urdf/openarm/assets/link6.dae new file mode 100644 index 0000000000000000000000000000000000000000..d20fc7795387bd971324c6ff120a2218d33f5230 --- /dev/null +++ b/public/urdf/openarm/assets/link6.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adcd37741f4e223bd6fe9925625e84191b525c9d27f151f9178b23989de05459 +size 6133097 diff --git a/public/urdf/openarm/assets/link6.stl b/public/urdf/openarm/assets/link6.stl new file mode 100644 index 0000000000000000000000000000000000000000..066a5ca28f3eceb75c384acb16dfd29a0cbdbcd2 --- /dev/null +++ b/public/urdf/openarm/assets/link6.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e49f91279f109baecb9ff54f5041eeb4514f757ba6daa65c3ea01fb1991967e4 +size 4818434 diff --git a/public/urdf/openarm/assets/link7.dae b/public/urdf/openarm/assets/link7.dae new file mode 100644 index 0000000000000000000000000000000000000000..cd45911d0010cd8a579936f6f942289075c798f6 --- /dev/null +++ b/public/urdf/openarm/assets/link7.dae @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0e1d59b1e09c5a73198cdf175dbb94844073ec067d4b491dcab8a9e1a7faade +size 6288812 diff --git a/public/urdf/openarm/assets/link7.stl b/public/urdf/openarm/assets/link7.stl new file mode 100644 index 0000000000000000000000000000000000000000..d0e853e53ed4e5c71d0e5a1d8d7c825553fef579 --- /dev/null +++ b/public/urdf/openarm/assets/link7.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a91593b67d2dec16d1dfb6f1305df3ddcd214cdef02e97d5aba30bc633e775b2 +size 5114784 diff --git a/public/urdf/openarm/openarm_bimanual.urdf b/public/urdf/openarm/openarm_bimanual.urdf new file mode 100644 index 0000000000000000000000000000000000000000..71955bb58737011658541d41365c1ba0ffa89368 --- /dev/null +++ b/public/urdf/openarm/openarm_bimanual.urdf @@ -0,0 +1,495 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/urdf/so101/assets/base_motor_holder_so101_v1.stl b/public/urdf/so101/assets/base_motor_holder_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..ac9c38076fe1036517faf0bccadea5de9dce0097 --- /dev/null +++ b/public/urdf/so101/assets/base_motor_holder_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cd2f241037ea377af1191fffe0dd9d9006beea6dcc48543660ed41647072424 +size 1877084 diff --git a/public/urdf/so101/assets/base_so101_v2.stl b/public/urdf/so101/assets/base_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..503d30be06a91e401ba8d46ebb7e650866229550 --- /dev/null +++ b/public/urdf/so101/assets/base_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb12b7026575e1f70ccc7240051f9d943553bf34e5128537de6cd86fae33924d +size 471584 diff --git a/public/urdf/so101/assets/motor_holder_so101_base_v1.stl b/public/urdf/so101/assets/motor_holder_so101_base_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..f8e3d75c027f28bb672f830ec6e0795567c1b7c9 --- /dev/null +++ b/public/urdf/so101/assets/motor_holder_so101_base_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31242ae6fb59d8b15c66617b88ad8e9bded62d57c35d11c0c43a70d2f4caa95b +size 1129384 diff --git a/public/urdf/so101/assets/motor_holder_so101_wrist_v1.stl b/public/urdf/so101/assets/motor_holder_so101_wrist_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..e55b7194683c6ac301504c6f59137362f0ebd13e --- /dev/null +++ b/public/urdf/so101/assets/motor_holder_so101_wrist_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:887f92e6013cb64ea3a1ab8675e92da1e0beacfd5e001f972523540545e08011 +size 1052184 diff --git a/public/urdf/so101/assets/moving_jaw_so101_v1.stl b/public/urdf/so101/assets/moving_jaw_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..eb17d253df8a84a88472ecc7f859d3b8b4d78884 --- /dev/null +++ b/public/urdf/so101/assets/moving_jaw_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:785a9dded2f474bc1d869e0d3dae398a3dcd9c0c345640040472210d2861fa9d +size 1413584 diff --git a/public/urdf/so101/assets/rotation_pitch_so101_v1.stl b/public/urdf/so101/assets/rotation_pitch_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..b536cb4100c1f204f8a9d9b182acdc4a3afbc66c --- /dev/null +++ b/public/urdf/so101/assets/rotation_pitch_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9be900cc2a2bf718102841ef82ef8d2873842427648092c8ed2ca1e2ef4ffa34 +size 883684 diff --git a/public/urdf/so101/assets/sts3215_03a_no_horn_v1.stl b/public/urdf/so101/assets/sts3215_03a_no_horn_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..18e9335673f6d46ea8fd0a03a791516203eb6f4c --- /dev/null +++ b/public/urdf/so101/assets/sts3215_03a_no_horn_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75ef3781b752e4065891aea855e34dc161a38a549549cd0970cedd07eae6f887 +size 865884 diff --git a/public/urdf/so101/assets/sts3215_03a_v1.stl b/public/urdf/so101/assets/sts3215_03a_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..a14c57b9033b82f1daa38f45e7e3c91343702df4 --- /dev/null +++ b/public/urdf/so101/assets/sts3215_03a_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a37c871fb502483ab96c256baf457d36f2e97afc9205313d9c5ab275ef941cd0 +size 954084 diff --git a/public/urdf/so101/assets/under_arm_so101_v1.stl b/public/urdf/so101/assets/under_arm_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..47b611ef939e452f791ae749756f717317922cfd --- /dev/null +++ b/public/urdf/so101/assets/under_arm_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d01d1f2de365651dcad9d6669e94ff87ff7652b5bb2d10752a66a456a86dbc71 +size 1975884 diff --git a/public/urdf/so101/assets/upper_arm_so101_v1.stl b/public/urdf/so101/assets/upper_arm_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..8832740f9540065e6006907a9a826b01f96cd122 --- /dev/null +++ b/public/urdf/so101/assets/upper_arm_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:475056e03a17e71919b82fd88ab9a0b898ab50164f2a7943652a6b2941bb2d4f +size 1303484 diff --git a/public/urdf/so101/assets/waveshare_mounting_plate_so101_v2.stl b/public/urdf/so101/assets/waveshare_mounting_plate_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..e0d90d5b6554ab8ca91928fa6521a675089beb12 --- /dev/null +++ b/public/urdf/so101/assets/waveshare_mounting_plate_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e197e24005a07d01bbc06a8c42311664eaeda415bf859f68fa247884d0f1a6e9 +size 62784 diff --git a/public/urdf/so101/assets/wrist_roll_follower_so101_v1.stl b/public/urdf/so101/assets/wrist_roll_follower_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..9a5fa8fe2d7d8e59cd4a30d4dba0ca337513ab4a --- /dev/null +++ b/public/urdf/so101/assets/wrist_roll_follower_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b17b410a12d64ec39554abc3e8054d8a97384b2dc4a8d95a5ecb2a93670f5f4 +size 1439884 diff --git a/public/urdf/so101/assets/wrist_roll_pitch_so101_v2.stl b/public/urdf/so101/assets/wrist_roll_pitch_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..2f531712f88ec01d09824ee8e27c791a4616516f --- /dev/null +++ b/public/urdf/so101/assets/wrist_roll_pitch_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c7ec5525b4d8b9e397a30ab4bb0037156a5d5f38a4adf2c7d943d6c56eda5ae +size 2699784 diff --git a/public/urdf/so101/so100.urdf b/public/urdf/so101/so100.urdf new file mode 100644 index 0000000000000000000000000000000000000000..5fff7561c452aedaae14e9e190a57ac75f6898dc --- /dev/null +++ b/public/urdf/so101/so100.urdf @@ -0,0 +1,406 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/urdf/so101/so101_new_calib.urdf b/public/urdf/so101/so101_new_calib.urdf new file mode 100644 index 0000000000000000000000000000000000000000..9552a231d8b23bed68ec15779eba620c5d875ec4 --- /dev/null +++ b/public/urdf/so101/so101_new_calib.urdf @@ -0,0 +1,453 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + \ No newline at end of file diff --git a/src/app/.well-known/appspecific/[...slug]/route.ts b/src/app/.well-known/appspecific/[...slug]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbc72c0ec5f7b13bf65e763d91246cf49359c50a --- /dev/null +++ b/src/app/.well-known/appspecific/[...slug]/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export function GET() { + return new NextResponse(null, { status: 404 }); +} diff --git a/src/app/[org]/[dataset]/[episode]/actions.ts b/src/app/[org]/[dataset]/[episode]/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bf95eb636f2258e302a0d6129025e3524457a61 --- /dev/null +++ b/src/app/[org]/[dataset]/[episode]/actions.ts @@ -0,0 +1,65 @@ +"use server"; + +import { getDatasetVersionAndInfo } from "@/utils/versionUtils"; +import type { DatasetMetadata } from "@/utils/parquetUtils"; +import { + loadAllEpisodeLengthsV3, + loadAllEpisodeFrameInfo, + loadCrossEpisodeActionVariance, + loadEpisodeFlatChartData, + type EpisodeLengthStats, + type EpisodeFramesData, + type CrossEpisodeVarianceData, +} from "./fetch-data"; + +export async function fetchEpisodeLengthStats( + org: string, + dataset: string, +): Promise { + const repoId = `${org}/${dataset}`; + const { version, info } = await getDatasetVersionAndInfo(repoId); + if (version !== "v3.0") return null; + return loadAllEpisodeLengthsV3(repoId, version, info.fps); +} + +export async function fetchEpisodeFrames( + org: string, + dataset: string, +): Promise { + const repoId = `${org}/${dataset}`; + const { version, info } = await getDatasetVersionAndInfo(repoId); + return loadAllEpisodeFrameInfo( + repoId, + version, + info as unknown as DatasetMetadata, + ); +} + +export async function fetchCrossEpisodeVariance( + org: string, + dataset: string, +): Promise { + const repoId = `${org}/${dataset}`; + const { version, info } = await getDatasetVersionAndInfo(repoId); + return loadCrossEpisodeActionVariance( + repoId, + version, + info as unknown as DatasetMetadata, + info.fps, + ); +} + +export async function fetchEpisodeChartData( + org: string, + dataset: string, + episodeId: number, +): Promise[]> { + const repoId = `${org}/${dataset}`; + const { version, info } = await getDatasetVersionAndInfo(repoId); + return loadEpisodeFlatChartData( + repoId, + version, + info as unknown as DatasetMetadata, + episodeId, + ); +} diff --git a/src/app/[org]/[dataset]/[episode]/episode-viewer.tsx b/src/app/[org]/[dataset]/[episode]/episode-viewer.tsx index eba1f5e3e454f83d77037f3f9d0b32a318a4f8dc..d1b823504637903f5264089b7e93658d257569ad 100644 --- a/src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +++ b/src/app/[org]/[dataset]/[episode]/episode-viewer.tsx @@ -1,16 +1,46 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, lazy, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { postParentMessageWithParams } from "@/utils/postParentMessage"; import { SimpleVideosPlayer } from "@/components/simple-videos-player"; import DataRecharts from "@/components/data-recharts"; import PlaybackBar from "@/components/playback-bar"; import { TimeProvider, useTime } from "@/context/time-context"; +import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context"; import Sidebar from "@/components/side-nav"; +import StatsPanel from "@/components/stats-panel"; +import OverviewPanel from "@/components/overview-panel"; import Loading from "@/components/loading-component"; -import { getAdjacentEpisodesVideoInfo } from "./fetch-data"; -import type { EpisodeData } from "@/types"; +import { hasURDFSupport } from "@/lib/so101-robot"; +import { + getAdjacentEpisodesVideoInfo, + computeColumnMinMax, + type EpisodeData, + type ColumnMinMax, + type EpisodeLengthStats, + type EpisodeFramesData, + type CrossEpisodeVarianceData, +} from "./fetch-data"; +import { + fetchEpisodeLengthStats, + fetchEpisodeFrames, + fetchCrossEpisodeVariance, +} from "./actions"; + +const URDFViewer = lazy(() => import("@/components/urdf-viewer")); +const ActionInsightsPanel = lazy( + () => import("@/components/action-insights-panel"), +); +const FilteringPanel = lazy(() => import("@/components/filtering-panel")); + +type ActiveTab = + | "episodes" + | "statistics" + | "frames" + | "insights" + | "filtering" + | "urdf"; export default function EpisodeViewer({ data, @@ -39,8 +69,10 @@ export default function EpisodeViewer({ } return ( - - + + + + ); } @@ -67,10 +99,127 @@ function EpisodeViewerInner({ const [chartsReady, setChartsReady] = useState(false); const isLoading = !videosReady || !chartsReady; + const loadStartRef = useRef(performance.now()); + useEffect(() => { + if (!isLoading) { + console.log( + `[perf] Loading complete in ${(performance.now() - loadStartRef.current).toFixed(0)}ms (videos: ${videosReady ? "✓" : "…"}, charts: ${chartsReady ? "✓" : "…"})`, + ); + } + }, [isLoading]); + const router = useRouter(); const searchParams = useSearchParams(); - // State + // Tab state & lazy stats + const [activeTab, setActiveTab] = useState("episodes"); + const [, setColumnMinMax] = useState(null); + const [episodeLengthStats, setEpisodeLengthStats] = + useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const statsLoadedRef = useRef(false); + const [episodeFramesData, setEpisodeFramesData] = + useState(null); + const [framesLoading, setFramesLoading] = useState(false); + const framesLoadedRef = useRef(false); + const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(false); + const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(false); + const [crossEpData, setCrossEpData] = + useState(null); + const [insightsLoading, setInsightsLoading] = useState(false); + const insightsLoadedRef = useRef(false); + + // Hydrate UI state from sessionStorage after mount (avoids SSR/client mismatch) + useEffect(() => { + const stored = sessionStorage.getItem("activeTab"); + if ( + stored && + [ + "episodes", + "statistics", + "frames", + "insights", + "filtering", + "urdf", + ].includes(stored) + ) { + setActiveTab(stored as ActiveTab); + } + if (sessionStorage.getItem("framesFlaggedOnly") === "true") + setFramesFlaggedOnly(true); + if (sessionStorage.getItem("sidebarFlaggedOnly") === "true") + setSidebarFlaggedOnly(true); + }, []); + + // Persist UI state across episode navigations + useEffect(() => { + sessionStorage.setItem("activeTab", activeTab); + }, [activeTab]); + useEffect(() => { + sessionStorage.setItem("sidebarFlaggedOnly", String(sidebarFlaggedOnly)); + }, [sidebarFlaggedOnly]); + useEffect(() => { + sessionStorage.setItem("framesFlaggedOnly", String(framesFlaggedOnly)); + }, [framesFlaggedOnly]); + + const loadStats = () => { + if (statsLoadedRef.current) return; + statsLoadedRef.current = true; + setStatsLoading(true); + setColumnMinMax(computeColumnMinMax(data.chartDataGroups)); + if (org && dataset) { + fetchEpisodeLengthStats(org, dataset) + .then((result) => setEpisodeLengthStats(result)) + .catch(() => {}) + .finally(() => setStatsLoading(false)); + } else { + setStatsLoading(false); + } + }; + + const loadFrames = () => { + if (framesLoadedRef.current || !org || !dataset) return; + framesLoadedRef.current = true; + setFramesLoading(true); + fetchEpisodeFrames(org, dataset) + .then(setEpisodeFramesData) + .catch(() => setEpisodeFramesData({ cameras: [], framesByCamera: {} })) + .finally(() => setFramesLoading(false)); + }; + + const loadInsights = () => { + if (insightsLoadedRef.current || !org || !dataset) return; + insightsLoadedRef.current = true; + setInsightsLoading(true); + fetchCrossEpisodeVariance(org, dataset) + .then(setCrossEpData) + .catch((err) => console.error("[cross-ep] Failed:", err)) + .finally(() => setInsightsLoading(false)); + }; + + // Re-trigger data loading for the restored tab on mount + useEffect(() => { + if (activeTab === "statistics") loadStats(); + if (activeTab === "frames") loadFrames(); + if (activeTab === "insights") loadInsights(); + if (activeTab === "filtering") { + loadStats(); + loadInsights(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleTabChange = (tab: ActiveTab) => { + setActiveTab(tab); + if (tab === "statistics") loadStats(); + if (tab === "frames") loadFrames(); + if (tab === "insights") loadInsights(); + if (tab === "filtering") { + loadStats(); + loadInsights(); + } + }; + // Use context for time sync const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime(); @@ -83,20 +232,29 @@ function EpisodeViewerInner({ currentPage * pageSize, ); - // Preload adjacent episodes' videos + // Preload adjacent episodes' videos via tags useEffect(() => { if (!org || !dataset) return; + const links: HTMLLinkElement[] = []; - const preloadAdjacent = async () => { - try { - await getAdjacentEpisodesVideoInfo(org, dataset, episodeId, 2); - // Preload adjacent episodes for smoother navigation - } catch { - // Skip preloading on error - } - }; + getAdjacentEpisodesVideoInfo(org, dataset, episodeId, 2) + .then((adjacentVideos) => { + for (const ep of adjacentVideos) { + for (const v of ep.videosInfo) { + const link = document.createElement("link"); + link.rel = "preload"; + link.as = "video"; + link.href = v.url; + document.head.appendChild(link); + links.push(link); + } + } + }) + .catch(() => {}); - preloadAdjacent(); + return () => { + links.forEach((l) => l.remove()); + }; }, [org, dataset, episodeId]); // Initialize based on URL time parameter @@ -189,86 +347,236 @@ function EpisodeViewerInner({ }; return ( -
- {/* Sidebar */} - - - {/* Content */} -
- {isLoading && } - - - {/* Videos */} - {videosInfo.length && ( - setVideosReady(true)} + {/* Body: sidebar + content */} +
+ {/* Sidebar — only on Episodes tab */} + {activeTab === "episodes" && ( + )} - {/* Language Instruction */} - {task && ( -
-

- - Language Instruction: - -

-
- {task.split("\n").map((instruction, index) => ( -

- {instruction} -

- ))} -
-
- )} + {/* Main content */} +
+ {isLoading && } - {/* Graph */} -
- setChartsReady(true)} - /> -
+ {activeTab === "episodes" && ( + <> +
+ + LeRobot Logo + - +
+ +

+ {datasetInfo.repoId} +

+
+ +

+ episode {episodeId} +

+
+
+ + {/* Videos */} + {videosInfo.length > 0 && ( + setVideosReady(true)} + /> + )} + + {/* Language Instruction */} + {task && ( +
+

+ + Language Instruction: + +

+
+ {task + .split("\n") + .map((instruction: string, index: number) => ( +

+ {instruction} +

+ ))} +
+
+ )} + + {/* Graph */} +
+ setChartsReady(true)} + /> +
+ + + + )} + + {activeTab === "statistics" && ( + + )} + + {activeTab === "frames" && ( + + )} + + {activeTab === "insights" && ( + }> + + + )} + + {activeTab === "filtering" && ( + }> + { + setSidebarFlaggedOnly(true); + handleTabChange("episodes"); + }} + /> + + )} + + {activeTab === "urdf" && ( + }> + + + )} +
); diff --git a/src/app/[org]/[dataset]/[episode]/fetch-data.ts b/src/app/[org]/[dataset]/[episode]/fetch-data.ts index b08cc97cf3c2a9fa7c3bdb5473bff7fb3aee80aa..99755696c2d43cdc0a56d9b067f714e4dc5ec805 100644 --- a/src/app/[org]/[dataset]/[episode]/fetch-data.ts +++ b/src/app/[org]/[dataset]/[episode]/fetch-data.ts @@ -1,32 +1,109 @@ import { - fetchJson, + DatasetMetadata, fetchParquetFile, formatStringWithVars, - readParquetColumn, readParquetAsObjects, } from "@/utils/parquetUtils"; import { pick } from "@/utils/pick"; -import { getDatasetVersion, buildVersionedUrl } from "@/utils/versionUtils"; +import { + getDatasetVersionAndInfo, + buildVersionedUrl, +} from "@/utils/versionUtils"; import { PADDING, CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants"; import { processChartDataGroups, groupRowBySuffix, } from "@/utils/dataProcessing"; -import { extractLanguageInstructions } from "@/utils/languageInstructions"; import { buildV3VideoPath, buildV3DataPath, buildV3EpisodesMetadataPath, } from "@/utils/stringFormatting"; import { bigIntToNumber } from "@/utils/typeGuards"; -import type { - DatasetMetadata, - EpisodeData, - EpisodeMetadataV3, - VideoInfo, - AdjacentEpisodeVideos, - ChartDataGroup, -} from "@/types"; +import type { VideoInfo, AdjacentEpisodeVideos } from "@/types"; + +const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER; + +export type CameraInfo = { name: string; width: number; height: number }; + +export type DatasetDisplayInfo = { + repoId: string; + total_frames: number; + total_episodes: number; + fps: number; + robot_type: string | null; + codebase_version: string; + total_tasks: number; + dataset_size_mb: number; + cameras: CameraInfo[]; +}; + +export type ChartRow = Record>; + +export type ColumnMinMax = { + column: string; + min: number; + max: number; +}; + +export type EpisodeLengthInfo = { + episodeIndex: number; + lengthSeconds: number; + frames: number; +}; + +export type EpisodeLengthStats = { + shortestEpisodes: EpisodeLengthInfo[]; + longestEpisodes: EpisodeLengthInfo[]; + allEpisodeLengths: EpisodeLengthInfo[]; + meanEpisodeLength: number; + medianEpisodeLength: number; + stdEpisodeLength: number; + episodeLengthHistogram: { binLabel: string; count: number }[]; +}; + +export type EpisodeFrameInfo = { + episodeIndex: number; + videoUrl: string; + firstFrameTime: number; + lastFrameTime: number | null; // null = seek to video.duration on client +}; + +export type EpisodeFramesData = { + cameras: string[]; + framesByCamera: Record; +}; + +export type EpisodeData = { + datasetInfo: DatasetDisplayInfo; + episodeId: number; + videosInfo: VideoInfo[]; + chartDataGroups: ChartRow[][]; + flatChartData: Record[]; + episodes: number[]; + ignoredColumns: string[]; + duration: number; + task?: string; +}; + +type EpisodeMetadataV3 = { + episode_index: number; + data_chunk_index: number; + data_file_index: number; + dataset_from_index: number; + dataset_to_index: number; + video_chunk_index: number; + video_file_index: number; + video_from_timestamp: number; + video_to_timestamp: number; + length: number; + [key: string]: string | number; +}; + +type ColumnDef = { + key: string; + value: string[]; +}; export async function getEpisodeData( org: string, @@ -35,10 +112,10 @@ export async function getEpisodeData( ): Promise { const repoId = `${org}/${dataset}`; try { - // Check for compatible dataset version (v3.0, v2.1, or v2.0) - const version = await getDatasetVersion(repoId); - const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json"); - const info = await fetchJson(jsonUrl); + console.time(`[perf] getDatasetVersionAndInfo`); + const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId); + console.timeEnd(`[perf] getDatasetVersionAndInfo`); + const info = rawInfo as unknown as DatasetMetadata; if (info.video_path === null) { throw new Error( @@ -46,19 +123,39 @@ export async function getEpisodeData( ); } - // Handle different versions - if (version === "v3.0") { - return await getEpisodeDataV3(repoId, version, info, episodeId); - } else { - return await getEpisodeDataV2(repoId, version, info, episodeId); - } + console.time(`[perf] getEpisodeData (${version})`); + const result = + version === "v3.0" + ? await getEpisodeDataV3(repoId, version, info, episodeId) + : await getEpisodeDataV2(repoId, version, info, episodeId); + console.timeEnd(`[perf] getEpisodeData (${version})`); + + // Extract camera resolutions from features + const cameras: CameraInfo[] = Object.entries(rawInfo.features) + .filter(([, f]) => f.dtype === "video" && f.shape.length >= 2) + .map(([name, f]) => ({ name, height: f.shape[0], width: f.shape[1] })); + + result.datasetInfo = { + ...result.datasetInfo, + robot_type: rawInfo.robot_type ?? null, + codebase_version: rawInfo.codebase_version, + total_tasks: rawInfo.total_tasks ?? 0, + dataset_size_mb: + Math.round( + ((rawInfo.data_files_size_in_mb ?? 0) + + (rawInfo.video_files_size_in_mb ?? 0)) * + 10, + ) / 10, + cameras, + }; + + return result; } catch (err) { console.error("Error loading episode data:", err); throw err; } } -// Get video info for adjacent episodes (for preloading) export async function getAdjacentEpisodesVideoInfo( org: string, dataset: string, @@ -67,9 +164,8 @@ export async function getAdjacentEpisodesVideoInfo( ): Promise { const repoId = `${org}/${dataset}`; try { - const version = await getDatasetVersion(repoId); - const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json"); - const info = await fetchJson(jsonUrl); + const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId); + const info = rawInfo as unknown as DatasetMetadata; const totalEpisodes = info.total_episodes; const adjacentVideos: AdjacentEpisodeVideos[] = []; @@ -142,12 +238,16 @@ async function getEpisodeDataV2( ): Promise { const episode_chunk = Math.floor(0 / 1000); - // Dataset information - const datasetInfo = { + const datasetInfo: DatasetDisplayInfo = { repoId, total_frames: info.total_frames, total_episodes: info.total_episodes, fps: info.fps, + robot_type: null, + codebase_version: version, + total_tasks: 0, + dataset_size_mb: 0, + cameras: [], }; // Generate list of episodes @@ -197,22 +297,17 @@ async function getEpisodeDataV2( const filteredColumns = columnNames.filter( (column) => !excludedColumns.includes(column.key), ); - const filteredColumnNames = [ - "timestamp", - ...filteredColumns.map((column) => column.key), - ]; - - const columns = filteredColumns.map(({ key }) => { - let column_names = info.features[key].names; - while (typeof column_names === "object") { + const columns: ColumnDef[] = filteredColumns.map(({ key }) => { + let column_names: unknown = info.features[key].names; + while (typeof column_names === "object" && column_names !== null) { if (Array.isArray(column_names)) break; - column_names = Object.values(column_names ?? {})[0]; + column_names = Object.values(column_names)[0]; } return { key, value: Array.isArray(column_names) ? column_names.map( - (name) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${name}`, + (name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`, ) : Array.from( { length: columnNames.find((c) => c.key === key)?.length ?? 1 }, @@ -233,32 +328,38 @@ async function getEpisodeDataV2( ); const arrayBuffer = await fetchParquetFile(parquetUrl); + const allData = await readParquetAsObjects(arrayBuffer, []); - // Extract task - first check for language instructions (preferred), then fallback to task field or tasks.jsonl + // Extract task from language_instruction fields, task field, or tasks.jsonl let task: string | undefined; - let allData: Record[] = []; - // Load data first - try { - allData = await readParquetAsObjects(arrayBuffer, []); - } catch { - // Could not read parquet data - } + if (allData.length > 0) { + const firstRow = allData[0]; + const languageInstructions: string[] = []; - // First check for language_instruction fields in the data (preferred) - task = extractLanguageInstructions(allData); + if (typeof firstRow.language_instruction === "string") { + languageInstructions.push(firstRow.language_instruction); + } - // If no language instructions found, try direct task field - if ( - !task && - allData.length > 0 && - typeof allData[0].task === "string" && - allData[0].task - ) { + let instructionNum = 2; + while ( + typeof firstRow[`language_instruction_${instructionNum}`] === "string" + ) { + languageInstructions.push( + firstRow[`language_instruction_${instructionNum}`] as string, + ); + instructionNum++; + } + + if (languageInstructions.length > 0) { + task = languageInstructions.join("\n"); + } + } + + if (!task && allData.length > 0 && typeof allData[0].task === "string") { task = allData[0].task; } - // If still no task found, try loading from tasks.jsonl metadata file (v2.x format) if (!task && allData.length > 0) { try { const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl"); @@ -266,7 +367,6 @@ async function getEpisodeDataV2( if (tasksResponse.ok) { const tasksText = await tasksResponse.text(); - // Parse JSONL format (one JSON object per line) const tasksData = tasksText .split("\n") .filter((line) => line.trim()) @@ -274,13 +374,11 @@ async function getEpisodeDataV2( if (tasksData && tasksData.length > 0) { const taskIndex = allData[0].task_index; - - // Convert BigInt to number for comparison const taskIndexNum = typeof taskIndex === "bigint" ? Number(taskIndex) : taskIndex; - - // Find task by task_index - const taskData = tasksData.find((t) => t.task_index === taskIndexNum); + const taskData = tasksData.find( + (t: Record) => t.task_index === taskIndexNum, + ); if (taskData) { task = taskData.task; } @@ -291,20 +389,25 @@ async function getEpisodeDataV2( } } - const data = await readParquetColumn(arrayBuffer, filteredColumnNames); - // Flatten and map to array of objects for chartData + // Build chart data from already-parsed allData (no second parquet parse) const seriesNames = [ "timestamp", ...columns.map(({ value }) => value).flat(), ]; - const chartData = data.map((row) => { - const flatRow = row.flat(); + const chartData = allData.map((row) => { const obj: Record = {}; - seriesNames.forEach((key, idx) => { - const value = flatRow[idx]; - obj[key] = typeof value === "number" ? value : Number(value) || 0; - }); + obj["timestamp"] = Number(row.timestamp); + for (const col of columns) { + const rawVal = row[col.key]; + if (Array.isArray(rawVal)) { + rawVal.forEach((v: unknown, i: number) => { + if (i < col.value.length) obj[col.value[i]] = Number(v); + }); + } else if (rawVal !== undefined) { + obj[col.value[0]] = Number(rawVal); + } + } return obj; }); @@ -338,6 +441,7 @@ async function getEpisodeDataV2( episodeId, videosInfo, chartDataGroups, + flatChartData: chartData, episodes, ignoredColumns, duration, @@ -352,15 +456,18 @@ async function getEpisodeDataV3( info: DatasetMetadata, episodeId: number, ): Promise { - // Create dataset info structure (like v2.x) - const datasetInfo = { + const datasetInfo: DatasetDisplayInfo = { repoId, total_frames: info.total_frames, total_episodes: info.total_episodes, fps: info.fps, + robot_type: null, + codebase_version: version, + total_tasks: 0, + dataset_size_mb: 0, + cameras: [], }; - // Generate episodes list based on total_episodes from dataset info const episodes = Array.from({ length: info.total_episodes }, (_, i) => i); // Load episode metadata to get timestamps for episode 0 @@ -379,25 +486,19 @@ async function getEpisodeDataV3( ); // Load episode data for charts - const { chartDataGroups, ignoredColumns, task } = await loadEpisodeDataV3( - repoId, - version, - info, - episodeMetadata, - ); + const { chartDataGroups, flatChartData, ignoredColumns, task } = + await loadEpisodeDataV3(repoId, version, info, episodeMetadata); - // Calculate duration from episode length and FPS if available - const episodeLength = bigIntToNumber(episodeMetadata.length); - const duration = episodeLength - ? episodeLength / info.fps - : (episodeMetadata.video_to_timestamp || 0) - - (episodeMetadata.video_from_timestamp || 0); + const duration = episodeMetadata.length + ? episodeMetadata.length / info.fps + : episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp; return { datasetInfo, episodeId, videosInfo, chartDataGroups, + flatChartData, episodes, ignoredColumns, duration, @@ -412,7 +513,8 @@ async function loadEpisodeDataV3( info: DatasetMetadata, episodeMetadata: EpisodeMetadataV3, ): Promise<{ - chartDataGroups: ChartDataGroup[]; + chartDataGroups: ChartRow[][]; + flatChartData: Record[]; ignoredColumns: string[]; task?: string; }> { @@ -427,9 +529,11 @@ async function loadEpisodeDataV3( const fullData = await readParquetAsObjects(arrayBuffer, []); // Extract the episode-specific data slice - // Convert BigInt to number if needed - const fromIndex = Number(episodeMetadata.dataset_from_index || 0); - const toIndex = Number(episodeMetadata.dataset_to_index || fullData.length); + const fromIndex = bigIntToNumber(episodeMetadata.dataset_from_index, 0); + const toIndex = bigIntToNumber( + episodeMetadata.dataset_to_index, + fullData.length, + ); // Find the starting index of this parquet file by checking the first row's index // This handles the case where episodes are split across multiple parquet files @@ -445,29 +549,57 @@ async function loadEpisodeDataV3( const episodeData = fullData.slice(localFromIndex, localToIndex); if (episodeData.length === 0) { - return { chartDataGroups: [], ignoredColumns: [], task: undefined }; + return { + chartDataGroups: [], + flatChartData: [], + ignoredColumns: [], + task: undefined, + }; } // Convert to the same format as v2.x for compatibility with existing chart code - const { chartDataGroups, ignoredColumns } = processEpisodeDataForCharts( - episodeData, - info, - episodeMetadata, - ); + const { chartDataGroups, flatChartData, ignoredColumns } = + processEpisodeDataForCharts(episodeData, info, episodeMetadata); // First check for language_instruction fields in the data (preferred) - // Check multiple rows: first, middle, and last - const sampleIndices = [ - 0, - Math.floor(episodeData.length / 2), - episodeData.length - 1, - ]; - let task = extractLanguageInstructions(episodeData, sampleIndices); - - // If no language instructions found, fall back to tasks metadata - if (!task) { + let task: string | undefined; + if (episodeData.length > 0) { + const languageInstructions: string[] = []; + + const extractInstructions = (row: Record) => { + if (typeof row.language_instruction === "string") { + languageInstructions.push(row.language_instruction); + } + let num = 2; + while (typeof row[`language_instruction_${num}`] === "string") { + languageInstructions.push( + row[`language_instruction_${num}`] as string, + ); + num++; + } + }; + + extractInstructions(episodeData[0]); + + // If no instructions in first row, check middle and last rows + if (languageInstructions.length === 0 && episodeData.length > 1) { + for (const idx of [ + Math.floor(episodeData.length / 2), + episodeData.length - 1, + ]) { + extractInstructions(episodeData[idx]); + if (languageInstructions.length > 0) break; + } + } + + if (languageInstructions.length > 0) { + task = languageInstructions.join("\n"); + } + } + + // Fall back to tasks metadata parquet + if (!task && episodeData.length > 0) { try { - // Load tasks metadata const tasksUrl = buildVersionedUrl( repoId, version, @@ -476,53 +608,28 @@ async function loadEpisodeDataV3( const tasksArrayBuffer = await fetchParquetFile(tasksUrl); const tasksData = await readParquetAsObjects(tasksArrayBuffer, []); - if ( - episodeData.length > 0 && - tasksData && - tasksData.length > 0 && - "task_index" in episodeData[0] - ) { - const taskIndex = episodeData[0].task_index; + if (tasksData.length > 0) { + const taskIndexNum = bigIntToNumber(episodeData[0].task_index, -1); - // Convert BigInt to number for comparison - const taskIndexNum = - typeof taskIndex === "bigint" - ? Number(taskIndex) - : typeof taskIndex === "number" - ? taskIndex - : undefined; - - // Look up task by index - if ( - taskIndexNum !== undefined && - taskIndexNum >= 0 && - taskIndexNum < tasksData.length - ) { + if (taskIndexNum >= 0 && taskIndexNum < tasksData.length) { const taskData = tasksData[taskIndexNum]; - // Extract task from various possible fields - if ( - taskData && - "__index_level_0__" in taskData && - typeof taskData.__index_level_0__ === "string" - ) { - task = taskData.__index_level_0__; - } else if ( - taskData && - "task" in taskData && - typeof taskData.task === "string" - ) { - task = taskData.task; - } + const rawTask = taskData.__index_level_0__ ?? taskData.task; + task = typeof rawTask === "string" ? rawTask : undefined; } } } catch { - // Could not load tasks metadata - dataset might not have language tasks + // Could not load tasks metadata } } - return { chartDataGroups, ignoredColumns, task }; + return { chartDataGroups, flatChartData, ignoredColumns, task }; } catch { - return { chartDataGroups: [], ignoredColumns: [], task: undefined }; + return { + chartDataGroups: [], + flatChartData: [], + ignoredColumns: [], + task: undefined, + }; } } @@ -531,16 +638,11 @@ function processEpisodeDataForCharts( episodeData: Record[], info: DatasetMetadata, episodeMetadata?: EpisodeMetadataV3, -): { chartDataGroups: ChartDataGroup[]; ignoredColumns: string[] } { - // Get numeric column features (not currently used but kept for reference) - // const columnNames = Object.entries(info.features) - // .filter( - // ([, value]) => - // ["float32", "int32"].includes(value.dtype) && - // value.shape.length === 1, - // ) - // .map(([key, value]) => ({ key, value })); - +): { + chartDataGroups: ChartRow[][]; + flatChartData: Record[]; + ignoredColumns: string[]; +} { // Convert parquet data to chart format let seriesNames: string[] = []; @@ -576,7 +678,7 @@ function processEpisodeDataForCharts( const excludedColumns = EXCLUDED_COLUMNS.V3 as readonly string[]; // Create columns structure similar to V2.1 for proper hierarchical naming - const columns = Object.entries(info.features) + const columns: ColumnDef[] = Object.entries(info.features) .filter( ([key, value]) => ["float32", "int32"].includes(value.dtype) && @@ -584,16 +686,16 @@ function processEpisodeDataForCharts( !excludedColumns.includes(key), ) .map(([key, feature]) => { - let column_names = feature.names; - while (typeof column_names === "object") { + let column_names: unknown = feature.names; + while (typeof column_names === "object" && column_names !== null) { if (Array.isArray(column_names)) break; - column_names = Object.values(column_names ?? {})[0]; + column_names = Object.values(column_names)[0]; } return { key, value: Array.isArray(column_names) ? column_names.map( - (name) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${name}`, + (name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`, ) : Array.from( { length: feature.shape[0] || 1 }, @@ -714,45 +816,6 @@ function processEpisodeDataForCharts( // Process chart data into organized groups using utility function const chartGroups = processChartDataGroups(seriesNames, chartData); - // Utility function to group row keys by suffix (same as V2.1) - function groupRowBySuffix(row: Record): { - timestamp: number; - [key: string]: number | Record; - } { - const result: { - timestamp: number; - [key: string]: number | Record; - } = { - timestamp: 0, - }; - const suffixGroups: Record> = {}; - for (const [key, value] of Object.entries(row)) { - if (key === "timestamp") { - result.timestamp = value; - continue; - } - const parts = key.split(CHART_CONFIG.SERIES_NAME_DELIMITER); - if (parts.length === 2) { - const [prefix, suffix] = parts; - if (!suffixGroups[suffix]) suffixGroups[suffix] = {}; - suffixGroups[suffix][prefix] = value; - } else { - result[key] = value; - } - } - for (const [suffix, group] of Object.entries(suffixGroups)) { - const keys = Object.keys(group); - if (keys.length === 1) { - // Use the full original name as the key - const fullName = `${keys[0]}${CHART_CONFIG.SERIES_NAME_DELIMITER}${suffix}`; - result[fullName] = group[keys[0]]; - } else { - result[suffix] = group; - } - } - return result; - } - const chartDataGroups = chartGroups.map((group) => chartData.map((row) => { const grouped = groupRowBySuffix(pick(row, [...group, "timestamp"])); @@ -765,7 +828,7 @@ function processEpisodeDataForCharts( }), ); - return { chartDataGroups, ignoredColumns }; + return { chartDataGroups, flatChartData: chartData, ignoredColumns }; } // Video info extraction with segmentation for v3.0 @@ -786,18 +849,22 @@ function extractVideoInfoV3WithSegmentation( key.startsWith(`videos/${videoKey}/`), ); - let chunkIndex, fileIndex, segmentStart, segmentEnd; + let chunkIndex: number, + fileIndex: number, + segmentStart: number, + segmentEnd: number; + + const toNum = (v: string | number): number => + typeof v === "string" ? parseFloat(v) || 0 : v; if (cameraSpecificKeys.length > 0) { - // Use camera-specific metadata - const chunkValue = episodeMetadata[`videos/${videoKey}/chunk_index`]; - const fileValue = episodeMetadata[`videos/${videoKey}/file_index`]; - chunkIndex = bigIntToNumber(chunkValue, 0); - fileIndex = bigIntToNumber(fileValue, 0); - segmentStart = episodeMetadata[`videos/${videoKey}/from_timestamp`] || 0; - segmentEnd = episodeMetadata[`videos/${videoKey}/to_timestamp`] || 30; + chunkIndex = toNum(episodeMetadata[`videos/${videoKey}/chunk_index`]); + fileIndex = toNum(episodeMetadata[`videos/${videoKey}/file_index`]); + segmentStart = + toNum(episodeMetadata[`videos/${videoKey}/from_timestamp`]) || 0; + segmentEnd = + toNum(episodeMetadata[`videos/${videoKey}/to_timestamp`]) || 30; } else { - // Fallback to generic video metadata chunkIndex = episodeMetadata.video_chunk_index || 0; fileIndex = episodeMetadata.video_file_index || 0; segmentStart = episodeMetadata.video_from_timestamp || 0; @@ -899,70 +966,83 @@ function parseEpisodeRowSimple( // Check if this is v3.0 format with named keys if ("episode_index" in row) { // v3.0 format - use named keys - const episodeData: Record = { - episode_index: bigIntToNumber(row["episode_index"], 0), - data_chunk_index: bigIntToNumber(row["data/chunk_index"], 0), - data_file_index: bigIntToNumber(row["data/file_index"], 0), - dataset_from_index: bigIntToNumber(row["dataset_from_index"], 0), - dataset_to_index: bigIntToNumber(row["dataset_to_index"], 0), - length: bigIntToNumber(row["length"], 0), + // Convert BigInt values to numbers + const toBigIntSafe = (value: unknown): number => { + if (typeof value === "bigint") return Number(value); + if (typeof value === "number") return value; + if (typeof value === "string") return parseInt(value) || 0; + return 0; + }; + + const toNumSafe = (value: unknown): number => { + if (typeof value === "number") return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") return parseFloat(value) || 0; + return 0; }; // Handle video metadata - look for video-specific keys const videoKeys = Object.keys(row).filter( (key) => key.includes("videos/") && key.includes("/chunk_index"), ); + let videoChunkIndex = 0, + videoFileIndex = 0, + videoFromTs = 0, + videoToTs = 30; if (videoKeys.length > 0) { - // Use the first video stream for basic info - const firstVideoKey = videoKeys[0]; - const videoBaseName = firstVideoKey.replace("/chunk_index", ""); - - episodeData.video_chunk_index = bigIntToNumber( - row[`${videoBaseName}/chunk_index`], - 0, - ); - episodeData.video_file_index = bigIntToNumber( - row[`${videoBaseName}/file_index`], - 0, - ); - episodeData.video_from_timestamp = bigIntToNumber( - row[`${videoBaseName}/from_timestamp`], - 0, - ); - episodeData.video_to_timestamp = bigIntToNumber( - row[`${videoBaseName}/to_timestamp`], - 0, - ); - } else { - // Fallback video values - episodeData.video_chunk_index = 0; - episodeData.video_file_index = 0; - episodeData.video_from_timestamp = 0; - episodeData.video_to_timestamp = 30; + const videoBaseName = videoKeys[0].replace("/chunk_index", ""); + videoChunkIndex = toBigIntSafe(row[`${videoBaseName}/chunk_index`]); + videoFileIndex = toBigIntSafe(row[`${videoBaseName}/file_index`]); + videoFromTs = toNumSafe(row[`${videoBaseName}/from_timestamp`]); + videoToTs = toNumSafe(row[`${videoBaseName}/to_timestamp`]) || 30; } - // Store the raw row data to preserve per-camera metadata - // This allows extractVideoInfoV3WithSegmentation to access camera-specific timestamps + const episodeData: EpisodeMetadataV3 = { + episode_index: toBigIntSafe(row["episode_index"]), + data_chunk_index: toBigIntSafe(row["data/chunk_index"]), + data_file_index: toBigIntSafe(row["data/file_index"]), + dataset_from_index: toBigIntSafe(row["dataset_from_index"]), + dataset_to_index: toBigIntSafe(row["dataset_to_index"]), + length: toBigIntSafe(row["length"]), + video_chunk_index: videoChunkIndex, + video_file_index: videoFileIndex, + video_from_timestamp: videoFromTs, + video_to_timestamp: videoToTs, + }; + + // Store per-camera metadata for extractVideoInfoV3WithSegmentation Object.keys(row).forEach((key) => { if (key.startsWith("videos/")) { - episodeData[key] = bigIntToNumber(row[key]); + const val = row[key]; + episodeData[key] = + typeof val === "bigint" + ? Number(val) + : typeof val === "number" || typeof val === "string" + ? val + : 0; } }); return episodeData as EpisodeMetadataV3; } else { // Fallback to numeric keys for compatibility + const toNum = (v: unknown, fallback = 0): number => + typeof v === "number" + ? v + : typeof v === "bigint" + ? Number(v) + : fallback; return { - episode_index: bigIntToNumber(row["0"], 0), - data_chunk_index: bigIntToNumber(row["1"], 0), - data_file_index: bigIntToNumber(row["2"], 0), - dataset_from_index: bigIntToNumber(row["3"], 0), - dataset_to_index: bigIntToNumber(row["4"], 0), - video_chunk_index: bigIntToNumber(row["5"], 0), - video_file_index: bigIntToNumber(row["6"], 0), - video_from_timestamp: bigIntToNumber(row["7"], 0), - video_to_timestamp: bigIntToNumber(row["8"], 30), - length: bigIntToNumber(row["9"], 30), + episode_index: toNum(row["0"]), + data_chunk_index: toNum(row["1"]), + data_file_index: toNum(row["2"]), + dataset_from_index: toNum(row["3"]), + dataset_to_index: toNum(row["4"]), + video_chunk_index: toNum(row["5"]), + video_file_index: toNum(row["6"]), + video_from_timestamp: toNum(row["7"]), + video_to_timestamp: toNum(row["8"], 30), + length: toNum(row["9"], 30), }; } } @@ -984,6 +1064,995 @@ function parseEpisodeRowSimple( return fallback; } +// ─── Stats computation ─────────────────────────────────────────── + +/** + * Compute per-column min/max values from the current episode's chart data. + */ +export function computeColumnMinMax( + chartDataGroups: ChartRow[][], +): ColumnMinMax[] { + const stats: Record = {}; + + for (const group of chartDataGroups) { + for (const row of group) { + for (const [key, value] of Object.entries(row)) { + if (key === "timestamp") continue; + if (typeof value === "number" && isFinite(value)) { + if (!stats[key]) { + stats[key] = { min: value, max: value }; + } else { + if (value < stats[key].min) stats[key].min = value; + if (value > stats[key].max) stats[key].max = value; + } + } else if (typeof value === "object" && value !== null) { + // Nested group like { joint_0: 1.2, joint_1: 3.4 } + for (const [subKey, subVal] of Object.entries(value)) { + const fullKey = `${key} | ${subKey}`; + if (typeof subVal === "number" && isFinite(subVal)) { + if (!stats[fullKey]) { + stats[fullKey] = { min: subVal, max: subVal }; + } else { + if (subVal < stats[fullKey].min) stats[fullKey].min = subVal; + if (subVal > stats[fullKey].max) stats[fullKey].max = subVal; + } + } + } + } + } + } + } + + return Object.entries(stats).map(([column, { min, max }]) => ({ + column, + min: Math.round(min * 1000) / 1000, + max: Math.round(max * 1000) / 1000, + })); +} + +/** + * Load all episode lengths from the episodes metadata parquet files (v3.0). + * Returns min/max/mean/median/std and a histogram, or null if unavailable. + */ +export async function loadAllEpisodeLengthsV3( + repoId: string, + version: string, + fps: number, +): Promise { + try { + const allEpisodes: { index: number; length: number }[] = []; + let fileIndex = 0; + const chunkIndex = 0; + + while (true) { + const path = `meta/episodes/chunk-${chunkIndex.toString().padStart(3, "0")}/file-${fileIndex.toString().padStart(3, "0")}.parquet`; + const url = buildVersionedUrl(repoId, version, path); + try { + const buf = await fetchParquetFile(url); + const rows = await readParquetAsObjects(buf, []); + if (rows.length === 0 && fileIndex > 0) break; + for (const row of rows) { + const parsed = parseEpisodeRowSimple(row); + allEpisodes.push({ + index: parsed.episode_index, + length: parsed.length, + }); + } + fileIndex++; + } catch { + break; + } + } + + if (allEpisodes.length === 0) return null; + + const withSeconds = allEpisodes.map((ep) => ({ + episodeIndex: ep.index, + frames: ep.length, + lengthSeconds: Math.round((ep.length / fps) * 100) / 100, + })); + + const sortedByLength = [...withSeconds].sort( + (a, b) => a.lengthSeconds - b.lengthSeconds, + ); + const shortestEpisodes = sortedByLength.slice(0, 5); + const longestEpisodes = sortedByLength.slice(-5).reverse(); + + const lengths = withSeconds.map((e) => e.lengthSeconds); + const sum = lengths.reduce((a, b) => a + b, 0); + const mean = Math.round((sum / lengths.length) * 100) / 100; + + const sorted = [...lengths].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 100) / 100 + : sorted[mid]; + + const variance = + lengths.reduce((acc, l) => acc + (l - mean) ** 2, 0) / lengths.length; + const std = Math.round(Math.sqrt(variance) * 100) / 100; + + // Build histogram + const histMin = Math.min(...lengths); + const histMax = Math.max(...lengths); + + if (histMax === histMin) { + return { + shortestEpisodes, + longestEpisodes, + allEpisodeLengths: withSeconds, + meanEpisodeLength: mean, + medianEpisodeLength: median, + stdEpisodeLength: std, + episodeLengthHistogram: [ + { binLabel: `${histMin.toFixed(1)}s`, count: lengths.length }, + ], + }; + } + + const p1 = sorted[Math.floor(sorted.length * 0.01)]; + const p99 = sorted[Math.ceil(sorted.length * 0.99) - 1]; + const range = p99 - p1 || 1; + + const targetBins = Math.max( + 10, + Math.min(50, Math.ceil(Math.log2(lengths.length) + 1)), + ); + const rawBinWidth = range / targetBins; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawBinWidth))); + const niceSteps = [1, 2, 2.5, 5, 10]; + const niceBinWidth = + niceSteps.map((s) => s * magnitude).find((w) => w >= rawBinWidth) ?? + rawBinWidth; + + const niceMin = Math.floor(p1 / niceBinWidth) * niceBinWidth; + const niceMax = Math.ceil(p99 / niceBinWidth) * niceBinWidth; + const actualBinCount = Math.max( + 1, + Math.round((niceMax - niceMin) / niceBinWidth), + ); + const bins = Array.from({ length: actualBinCount }, () => 0); + + for (const len of lengths) { + let binIdx = Math.floor((len - niceMin) / niceBinWidth); + if (binIdx < 0) binIdx = 0; + if (binIdx >= actualBinCount) binIdx = actualBinCount - 1; + bins[binIdx]++; + } + + const histogram = bins.map((count, i) => { + const lo = niceMin + i * niceBinWidth; + const hi = lo + niceBinWidth; + return { binLabel: `${lo.toFixed(1)}–${hi.toFixed(1)}s`, count }; + }); + + return { + shortestEpisodes, + longestEpisodes, + allEpisodeLengths: withSeconds, + meanEpisodeLength: mean, + medianEpisodeLength: median, + stdEpisodeLength: std, + episodeLengthHistogram: histogram, + }; + } catch { + return null; + } +} + +/** + * Load video frame info for all episodes across all cameras. + * Returns camera names + a map of camera → EpisodeFrameInfo[]. + */ +export async function loadAllEpisodeFrameInfo( + repoId: string, + version: string, + info: DatasetMetadata, +): Promise { + const videoFeatures = Object.entries(info.features).filter( + ([, f]) => f.dtype === "video", + ); + if (videoFeatures.length === 0) return { cameras: [], framesByCamera: {} }; + + const cameras = videoFeatures.map(([key]) => key); + const framesByCamera: Record = {}; + for (const cam of cameras) framesByCamera[cam] = []; + + if (version === "v3.0") { + let fileIndex = 0; + while (true) { + const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`; + try { + const buf = await fetchParquetFile( + buildVersionedUrl(repoId, version, path), + ); + const rows = await readParquetAsObjects(buf, []); + if (rows.length === 0 && fileIndex > 0) break; + for (const row of rows) { + const epIdx = Number(row["episode_index"] ?? 0); + for (const cam of cameras) { + const cIdx = Number( + row[`videos/${cam}/chunk_index`] ?? row["video_chunk_index"] ?? 0, + ); + const fIdx = Number( + row[`videos/${cam}/file_index`] ?? row["video_file_index"] ?? 0, + ); + const fromTs = Number( + row[`videos/${cam}/from_timestamp`] ?? + row["video_from_timestamp"] ?? + 0, + ); + const toTs = Number( + row[`videos/${cam}/to_timestamp`] ?? + row["video_to_timestamp"] ?? + 30, + ); + const videoPath = `videos/${cam}/chunk-${cIdx.toString().padStart(3, "0")}/file-${fIdx.toString().padStart(3, "0")}.mp4`; + framesByCamera[cam].push({ + episodeIndex: epIdx, + videoUrl: buildVersionedUrl(repoId, version, videoPath), + firstFrameTime: fromTs, + lastFrameTime: Math.max(0, toTs - 0.05), + }); + } + } + fileIndex++; + } catch { + break; + } + } + return { cameras, framesByCamera }; + } + + // v2.x — construct URLs from template + for (let i = 0; i < info.total_episodes; i++) { + const chunk = Math.floor(i / (info.chunks_size || 1000)); + for (const cam of cameras) { + const videoPath = formatStringWithVars(info.video_path, { + video_key: cam, + episode_chunk: chunk.toString().padStart(3, "0"), + episode_index: i.toString().padStart(6, "0"), + }); + framesByCamera[cam].push({ + episodeIndex: i, + videoUrl: buildVersionedUrl(repoId, version, videoPath), + firstFrameTime: 0, + lastFrameTime: null, + }); + } + } + return { cameras, framesByCamera }; +} + +// ─── Cross-episode action variance ────────────────────────────── + +export type LowMovementEpisode = { + episodeIndex: number; + totalMovement: number; +}; + +export type AggVelocityStat = { + name: string; + std: number; // normalized by motor range + maxAbs: number; // normalized by motor range + bins: number[]; + lo: number; // normalized by motor range + hi: number; // normalized by motor range + motorRange: number; + inactive?: boolean; // true if p95(|Δa|) < 1% of motor range + discrete?: boolean; // true if motor has very few unique values (e.g. open/close gripper) +}; + +export type AggAutocorrelation = { + chartData: Record[]; + suggestedChunk: number | null; + shortKeys: string[]; +}; + +export type SpeedDistEntry = { + episodeIndex: number; + speed: number; +}; + +export type AggAlignment = { + ccData: { lag: number; max: number; mean: number; min: number }[]; + meanPeakLag: number; + meanPeakCorr: number; + maxPeakLag: number; + maxPeakCorr: number; + minPeakLag: number; + minPeakCorr: number; + lagRangeMin: number; + lagRangeMax: number; + numPairs: number; +}; + +export type JerkyEpisode = { + episodeIndex: number; + meanAbsDelta: number; +}; + +export type CrossEpisodeVarianceData = { + actionNames: string[]; + timeBins: number[]; + variance: number[][]; + numEpisodes: number; + lowMovementEpisodes: LowMovementEpisode[]; + aggVelocity: AggVelocityStat[]; + aggAutocorrelation: AggAutocorrelation | null; + speedDistribution: SpeedDistEntry[]; + jerkyEpisodes: JerkyEpisode[]; + aggAlignment: AggAlignment | null; +}; + +export async function loadCrossEpisodeActionVariance( + repoId: string, + version: string, + info: DatasetMetadata, + fps: number, + maxEpisodes = 500, + numTimeBins = 50, +): Promise { + const actionEntry = Object.entries(info.features).find( + ([key, f]) => key === "action" && f.shape.length === 1, + ); + if (!actionEntry) { + console.warn( + "[cross-ep] No action feature found. Available features:", + Object.entries(info.features) + .map(([k, f]) => `${k}(${f.dtype}, shape=${JSON.stringify(f.shape)})`) + .join(", "), + ); + return null; + } + + const [actionKey, actionMeta] = actionEntry; + const actionDim = actionMeta.shape[0]; + + let names: unknown = actionMeta.names; + while (typeof names === "object" && names !== null && !Array.isArray(names)) { + names = Object.values(names)[0]; + } + const actionNames = Array.isArray(names) + ? (names as string[]).map((n) => `${actionKey}${SERIES_NAME_DELIMITER}${n}`) + : Array.from( + { length: actionDim }, + (_, i) => `${actionKey}${SERIES_NAME_DELIMITER}${i}`, + ); + + // State feature for alignment computation + const stateEntry = Object.entries(info.features).find( + ([key, f]) => key === "observation.state" && f.shape.length === 1, + ); + const stateKey = stateEntry?.[0] ?? null; + const stateDim = stateEntry?.[1].shape[0] ?? 0; + + // Collect episode metadata + type EpMeta = { + index: number; + chunkIdx: number; + fileIdx: number; + from: number; + to: number; + }; + const allEps: EpMeta[] = []; + + if (version === "v3.0") { + let fileIndex = 0; + while (true) { + const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`; + try { + const buf = await fetchParquetFile( + buildVersionedUrl(repoId, version, path), + ); + const rows = await readParquetAsObjects(buf, []); + if (rows.length === 0 && fileIndex > 0) break; + for (const row of rows) { + const parsed = parseEpisodeRowSimple(row); + allEps.push({ + index: parsed.episode_index, + chunkIdx: parsed.data_chunk_index, + fileIdx: parsed.data_file_index, + from: parsed.dataset_from_index, + to: parsed.dataset_to_index, + }); + } + fileIndex++; + } catch { + break; + } + } + } else { + for (let i = 0; i < info.total_episodes; i++) { + allEps.push({ index: i, chunkIdx: 0, fileIdx: 0, from: 0, to: 0 }); + } + } + + if (allEps.length < 2) { + console.warn( + `[cross-ep] Only ${allEps.length} episode(s) found in metadata, need ≥2`, + ); + return null; + } + console.log( + `[cross-ep] Found ${allEps.length} episodes in metadata, sampling up to ${maxEpisodes}`, + ); + + // Sample episodes evenly + const sampled = + allEps.length <= maxEpisodes + ? allEps + : Array.from( + { length: maxEpisodes }, + (_, i) => + allEps[Math.round((i * (allEps.length - 1)) / (maxEpisodes - 1))], + ); + + // Load action (and state) data per episode + const episodeActions: { index: number; actions: number[][] }[] = []; + const episodeStates: (number[][] | null)[] = []; + + if (version === "v3.0") { + const byFile = new Map(); + for (const ep of sampled) { + const key = `${ep.chunkIdx}-${ep.fileIdx}`; + if (!byFile.has(key)) byFile.set(key, []); + byFile.get(key)!.push(ep); + } + + for (const [, eps] of byFile) { + const ep0 = eps[0]; + const dataPath = `data/chunk-${ep0.chunkIdx.toString().padStart(3, "0")}/file-${ep0.fileIdx.toString().padStart(3, "0")}.parquet`; + try { + const buf = await fetchParquetFile( + buildVersionedUrl(repoId, version, dataPath), + ); + const rows = await readParquetAsObjects(buf, []); + const fileStart = + rows.length > 0 && rows[0].index !== undefined + ? Number(rows[0].index) + : 0; + + for (const ep of eps) { + const localFrom = Math.max(0, ep.from - fileStart); + const localTo = Math.min(rows.length, ep.to - fileStart); + const actions: number[][] = []; + const states: number[][] = []; + for (let r = localFrom; r < localTo; r++) { + const raw = rows[r]?.[actionKey]; + if (Array.isArray(raw)) actions.push(raw.map(Number)); + if (stateKey) { + const sRaw = rows[r]?.[stateKey]; + if (Array.isArray(sRaw)) states.push(sRaw.map(Number)); + } + } + if (actions.length > 0) { + episodeActions.push({ index: ep.index, actions }); + episodeStates.push( + stateKey && states.length === actions.length ? states : null, + ); + } + } + } catch { + /* skip file */ + } + } + } else { + const chunkSize = info.chunks_size || 1000; + for (const ep of sampled) { + const chunk = Math.floor(ep.index / chunkSize); + const dataPath = formatStringWithVars(info.data_path, { + episode_chunk: chunk.toString().padStart(3, "0"), + episode_index: ep.index.toString().padStart(6, "0"), + }); + try { + const buf = await fetchParquetFile( + buildVersionedUrl(repoId, version, dataPath), + ); + const rows = await readParquetAsObjects(buf, []); + const actions: number[][] = []; + const states: number[][] = []; + for (const row of rows) { + const raw = row[actionKey]; + if (Array.isArray(raw)) { + actions.push(raw.map(Number)); + } else { + const vec: number[] = []; + for (let d = 0; d < actionDim; d++) { + const v = row[`${actionKey}.${d}`] ?? row[d]; + vec.push(typeof v === "number" ? v : Number(v) || 0); + } + actions.push(vec); + } + if (stateKey) { + const sRaw = row[stateKey]; + if (Array.isArray(sRaw)) states.push(sRaw.map(Number)); + } + } + if (actions.length > 0) { + episodeActions.push({ index: ep.index, actions }); + episodeStates.push( + stateKey && states.length === actions.length ? states : null, + ); + } + } catch { + /* skip */ + } + } + } + + if (episodeActions.length < 2) { + console.warn( + `[cross-ep] Only ${episodeActions.length} episode(s) had loadable action data out of ${sampled.length} sampled`, + ); + return null; + } + console.log( + `[cross-ep] Loaded action data for ${episodeActions.length}/${sampled.length} episodes`, + ); + + // Resample each episode to numTimeBins and compute variance + const timeBins = Array.from( + { length: numTimeBins }, + (_, i) => i / (numTimeBins - 1), + ); + const sums = Array.from( + { length: numTimeBins }, + () => new Float64Array(actionDim), + ); + const sumsSq = Array.from( + { length: numTimeBins }, + () => new Float64Array(actionDim), + ); + const counts = new Uint32Array(numTimeBins); + + for (const { actions: epActions } of episodeActions) { + const T = epActions.length; + for (let b = 0; b < numTimeBins; b++) { + const srcIdx = Math.min(Math.round(timeBins[b] * (T - 1)), T - 1); + const row = epActions[srcIdx]; + for (let d = 0; d < actionDim; d++) { + const v = row[d] ?? 0; + sums[b][d] += v; + sumsSq[b][d] += v * v; + } + counts[b]++; + } + } + + const variance: number[][] = []; + for (let b = 0; b < numTimeBins; b++) { + const row: number[] = []; + const n = counts[b]; + for (let d = 0; d < actionDim; d++) { + if (n < 2) { + row.push(0); + continue; + } + const mean = sums[b][d] / n; + row.push(sumsSq[b][d] / n - mean * mean); + } + variance.push(row); + } + + // Per-episode average movement per frame: mean L2 norm of frame-to-frame action deltas + const movementScores: LowMovementEpisode[] = episodeActions.map( + ({ index, actions: ep }) => { + if (ep.length < 2) return { episodeIndex: index, totalMovement: 0 }; + let total = 0; + for (let t = 1; t < ep.length; t++) { + let sumSq = 0; + for (let d = 0; d < actionDim; d++) { + const delta = (ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0); + sumSq += delta * delta; + } + total += Math.sqrt(sumSq); + } + const avgPerFrame = total / (ep.length - 1); + return { + episodeIndex: index, + totalMovement: Math.round(avgPerFrame * 10000) / 10000, + }; + }, + ); + + movementScores.sort((a, b) => a.totalMovement - b.totalMovement); + const lowMovementEpisodes = movementScores.slice(0, 10); + + // Precompute per-dimension normalization: motor range (max − min) and unique value count + const motorRanges: number[] = new Array(actionDim); + const motorUniqueCount: number[] = new Array(actionDim); + const DISCRETE_THRESHOLD = 4; // ≤ this many unique values → discrete motor + for (let d = 0; d < actionDim; d++) { + let lo = Infinity, + hi = -Infinity; + const uniqueVals = new Set(); + for (const { actions: ep } of episodeActions) { + for (let t = 0; t < ep.length; t++) { + const v = ep[t][d] ?? 0; + if (v < lo) lo = v; + if (v > hi) hi = v; + if (uniqueVals.size <= DISCRETE_THRESHOLD) uniqueVals.add(v); + } + } + motorRanges[d] = hi - lo || 1; + motorUniqueCount[d] = uniqueVals.size; + } + + // Per-episode, per-dimension activity: p95(|Δa|) >= 1% of motor range + const ACTIVITY_THRESHOLD = 0.001; // 0.1% of motor range + // activeMap[episodeIdx][dimIdx] = true if motor d is active in that episode + const activeMap: boolean[][] = episodeActions.map(({ actions: ep }) => { + const flags: boolean[] = new Array(actionDim); + for (let d = 0; d < actionDim; d++) { + if (ep.length < 2) { + flags[d] = false; + continue; + } + const absDeltas: number[] = []; + for (let t = 1; t < ep.length; t++) { + absDeltas.push(Math.abs((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0))); + } + absDeltas.sort((a, b) => a - b); + const p95 = absDeltas[Math.floor(absDeltas.length * 0.95)]; + flags[d] = p95 >= motorRanges[d] * ACTIVITY_THRESHOLD; + } + return flags; + }); + // A motor is globally inactive only if inactive in all episodes + const globallyActive: boolean[] = new Array(actionDim); + for (let d = 0; d < actionDim; d++) { + globallyActive[d] = activeMap.some((flags) => flags[d]); + } + + // Aggregated velocity stats: pool deltas from all episodes, normalized by motor range + const shortName = (k: string) => { + const p = k.split(SERIES_NAME_DELIMITER); + return p.length > 1 ? p[p.length - 1] : k; + }; + + const aggVelocity: AggVelocityStat[] = (() => { + const binCount = 30; + const results: AggVelocityStat[] = []; + for (let d = 0; d < actionDim; d++) { + const motorRange = motorRanges[d]; + const inactive = !globallyActive[d]; + // Collect all deltas (unfiltered) for histogram display + const allDeltas: number[] = []; + // Collect only deltas from active episodes for stats + const activeDeltas: number[] = []; + for (let ei = 0; ei < episodeActions.length; ei++) { + const ep = episodeActions[ei].actions; + for (let t = 1; t < ep.length; t++) { + const delta = (ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0); + allDeltas.push(delta); + if (activeMap[ei][d]) activeDeltas.push(delta); + } + } + const deltas = activeDeltas.length > 0 ? activeDeltas : allDeltas; + const nUnique = motorUniqueCount[d]; + const discrete = nUnique <= DISCRETE_THRESHOLD; + if (deltas.length === 0) { + results.push({ + name: shortName(actionNames[d]), + std: 0, + maxAbs: 0, + bins: new Array(binCount).fill(0), + lo: 0, + hi: 0, + motorRange, + inactive, + discrete, + }); + continue; + } + let sum = 0, + maxAbsRaw = 0, + loRaw = Infinity, + hiRaw = -Infinity; + for (const v of deltas) { + sum += v; + const a = Math.abs(v); + if (a > maxAbsRaw) maxAbsRaw = a; + if (v < loRaw) loRaw = v; + if (v > hiRaw) hiRaw = v; + } + const mean = sum / deltas.length; + let varSum = 0; + for (const v of deltas) varSum += (v - mean) ** 2; + const rawStd = Math.sqrt(varSum / deltas.length); + const std = rawStd / motorRange; + const maxAbs = maxAbsRaw / motorRange; + const lo = loRaw / motorRange; + const hi = hiRaw / motorRange; + const range = hi - lo || 1; + const binW = range / binCount; + const bins = new Array(binCount).fill(0); + for (const v of deltas) { + const normV = v / motorRange; + let b = Math.floor((normV - lo) / binW); + if (b >= binCount) b = binCount - 1; + bins[b]++; + } + results.push({ + name: shortName(actionNames[d]), + std, + maxAbs, + bins, + lo, + hi, + motorRange, + inactive, + discrete, + }); + } + return results; + })(); + + // Aggregated autocorrelation: average per-episode ACFs + const aggAutocorrelation: AggAutocorrelation | null = (() => { + const maxLag = Math.min( + 100, + Math.floor( + episodeActions.reduce( + (min, e) => Math.min(min, e.actions.length), + Infinity, + ) / 2, + ), + ); + if (maxLag < 2) return null; + + const avgAcf: number[][] = Array.from({ length: actionDim }, () => + new Array(maxLag).fill(0), + ); + let epCount = 0; + + for (const { actions: ep } of episodeActions) { + if (ep.length < maxLag * 2) continue; + epCount++; + for (let d = 0; d < actionDim; d++) { + const vals = ep.map((row) => row[d] ?? 0); + const n = vals.length; + const m = vals.reduce((a, b) => a + b, 0) / n; + const centered = vals.map((v) => v - m); + const vari = centered.reduce((a, v) => a + v * v, 0); + if (vari === 0) continue; + for (let lag = 1; lag <= maxLag; lag++) { + let s = 0; + for (let t = 0; t < n - lag; t++) + s += centered[t] * centered[t + lag]; + avgAcf[d][lag - 1] += s / vari; + } + } + } + + if (epCount === 0) return null; + for (let d = 0; d < actionDim; d++) + for (let l = 0; l < maxLag; l++) avgAcf[d][l] /= epCount; + + const shortKeys = actionNames.map(shortName); + const chartData = Array.from({ length: maxLag }, (_, lag) => { + const row: Record = { + lag: lag + 1, + time: (lag + 1) / fps, + }; + shortKeys.forEach((k, d) => { + row[k] = avgAcf[d][lag]; + }); + return row; + }); + + // Suggested chunk: median lag where ACF drops below 0.5 + const lags = avgAcf + .map((acf) => { + const i = acf.findIndex((v) => v < 0.5); + return i >= 0 ? i + 1 : null; + }) + .filter(Boolean) as number[]; + const suggestedChunk = + lags.length > 0 + ? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)] + : null; + + return { chartData, suggestedChunk, shortKeys }; + })(); + + // Per-episode jerkiness: mean |Δa| across dimensions active in that episode, normalized by motor range + const jerkyEpisodes: JerkyEpisode[] = episodeActions + .map(({ index, actions: ep }, ei) => { + let sum = 0, + count = 0; + for (let t = 1; t < ep.length; t++) { + for (let d = 0; d < actionDim; d++) { + if (!activeMap[ei][d]) continue; // skip motors inactive in this episode + sum += + Math.abs((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0)) / motorRanges[d]; + count++; + } + } + return { episodeIndex: index, meanAbsDelta: count > 0 ? sum / count : 0 }; + }) + .sort((a, b) => b.meanAbsDelta - a.meanAbsDelta); + + // Speed distribution: all episode movement scores (not just lowest 10) + const speedDistribution: SpeedDistEntry[] = movementScores.map((s) => ({ + episodeIndex: s.episodeIndex, + speed: s.totalMovement, + })); + + // Aggregated state-action alignment across episodes + const aggAlignment: AggAlignment | null = (() => { + if (!stateKey || stateDim === 0) return null; + + let sNms: unknown = stateEntry![1].names; + while (typeof sNms === "object" && sNms !== null && !Array.isArray(sNms)) + sNms = Object.values(sNms)[0]; + const stateNames = Array.isArray(sNms) + ? (sNms as string[]) + : Array.from({ length: stateDim }, (_, i) => `${i}`); + const actionSuffixes = actionNames.map((n) => { + const p = n.split(SERIES_NAME_DELIMITER); + return p[p.length - 1]; + }); + + // Match pairs by suffix, fall back to index + const pairs: [number, number][] = []; + for (let ai = 0; ai < actionDim; ai++) { + const si = stateNames.findIndex((s) => s === actionSuffixes[ai]); + if (si >= 0) pairs.push([ai, si]); + } + if (pairs.length === 0) { + const count = Math.min(actionDim, stateDim); + for (let i = 0; i < count; i++) pairs.push([i, i]); + } + if (pairs.length === 0) return null; + + const maxLag = 30; + const numLags = 2 * maxLag + 1; + const corrSums = pairs.map(() => new Float64Array(numLags)); + const corrCounts = pairs.map(() => new Uint32Array(numLags)); + + for (let ei = 0; ei < episodeActions.length; ei++) { + const states = episodeStates[ei]; + if (!states) continue; + const { actions } = episodeActions[ei]; + const n = Math.min(actions.length, states.length); + if (n < 10) continue; + + for (let pi = 0; pi < pairs.length; pi++) { + const [ai, si] = pairs[pi]; + const aVals = actions.slice(0, n).map((r) => r[ai] ?? 0); + const sDeltas = Array.from( + { length: n - 1 }, + (_, t) => (states[t + 1][si] ?? 0) - (states[t][si] ?? 0), + ); + const effN = Math.min(aVals.length, sDeltas.length); + const aM = aVals.slice(0, effN).reduce((a, b) => a + b, 0) / effN; + const sM = sDeltas.slice(0, effN).reduce((a, b) => a + b, 0) / effN; + + for (let li = 0; li < numLags; li++) { + const lag = -maxLag + li; + let sum = 0, + aV = 0, + sV = 0; + for (let t = 0; t < effN; t++) { + const sIdx = t + lag; + if (sIdx < 0 || sIdx >= sDeltas.length) continue; + const a = aVals[t] - aM, + s = sDeltas[sIdx] - sM; + sum += a * s; + aV += a * a; + sV += s * s; + } + const d = Math.sqrt(aV * sV); + if (d > 0) { + corrSums[pi][li] += sum / d; + corrCounts[pi][li]++; + } + } + } + } + + const avgCorrs = pairs.map((_, pi) => + Array.from({ length: numLags }, (_, li) => + corrCounts[pi][li] > 0 ? corrSums[pi][li] / corrCounts[pi][li] : 0, + ), + ); + + const ccData = Array.from({ length: numLags }, (_, li) => { + const lag = -maxLag + li; + const vals = avgCorrs.map((pc) => pc[li]); + return { + lag, + max: Math.max(...vals), + mean: vals.reduce((a, b) => a + b, 0) / vals.length, + min: Math.min(...vals), + }; + }); + + let meanPeakLag = 0, + meanPeakCorr = -Infinity; + let maxPeakLag = 0, + maxPeakCorr = -Infinity; + let minPeakLag = 0, + minPeakCorr = -Infinity; + for (const row of ccData) { + if (row.max > maxPeakCorr) { + maxPeakCorr = row.max; + maxPeakLag = row.lag; + } + if (row.mean > meanPeakCorr) { + meanPeakCorr = row.mean; + meanPeakLag = row.lag; + } + if (row.min > minPeakCorr) { + minPeakCorr = row.min; + minPeakLag = row.lag; + } + } + + const perPairPeakLags = avgCorrs.map((pc) => { + let best = -Infinity, + bestLag = 0; + for (let li = 0; li < pc.length; li++) { + if (pc[li] > best) { + best = pc[li]; + bestLag = -maxLag + li; + } + } + return bestLag; + }); + + return { + ccData, + meanPeakLag, + meanPeakCorr, + maxPeakLag, + maxPeakCorr, + minPeakLag, + minPeakCorr, + lagRangeMin: Math.min(...perPairPeakLags), + lagRangeMax: Math.max(...perPairPeakLags), + numPairs: pairs.length, + }; + })(); + + return { + actionNames, + timeBins, + variance, + numEpisodes: episodeActions.length, + lowMovementEpisodes, + aggVelocity, + aggAutocorrelation, + speedDistribution, + jerkyEpisodes, + aggAlignment, + }; +} + +// Load only flatChartData for a specific episode (used by URDF viewer episode switching) +export async function loadEpisodeFlatChartData( + repoId: string, + version: string, + info: DatasetMetadata, + episodeId: number, +): Promise[]> { + const episodeMetadata = await loadEpisodeMetadataV3Simple( + repoId, + version, + episodeId, + ); + const { flatChartData } = await loadEpisodeDataV3( + repoId, + version, + info, + episodeMetadata, + ); + return flatChartData; +} + // Safe wrapper for UI error display export async function getEpisodeDataSafe( org: string, @@ -993,10 +2062,8 @@ export async function getEpisodeDataSafe( try { const data = await getEpisodeData(org, dataset, episodeId); return { data }; - } catch (err) { - // Only expose the error message, not stack or sensitive info - const errorMessage = - err instanceof Error ? err.message : String(err) || "Unknown error"; - return { error: errorMessage }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { error: message || "Unknown error" }; } } diff --git a/src/app/[org]/[dataset]/[episode]/page.tsx b/src/app/[org]/[dataset]/[episode]/page.tsx index 4ae7324d93373b48442a8f8b71fe5f2d0c3459cb..1c631cf4d0dcca28fb44f706eebc7d53a3ed405d 100644 --- a/src/app/[org]/[dataset]/[episode]/page.tsx +++ b/src/app/[org]/[dataset]/[episode]/page.tsx @@ -27,7 +27,7 @@ export default async function EpisodePage({ const { data, error } = await getEpisodeDataSafe(org, dataset, episodeNumber); return ( - + ); } diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 064bbd2774d6c659246e46d7be246d1045f6feca..e4ce14e38dd03e4f8454c57a73fa7d5ea2764df1 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -2,7 +2,7 @@ import React from "react"; import ExploreGrid from "./explore-grid"; import { fetchJson, formatStringWithVars } from "@/utils/parquetUtils"; import { getDatasetVersion, buildVersionedUrl } from "@/utils/versionUtils"; -import type { DatasetMetadata } from "@/types"; +import type { DatasetMetadata } from "@/utils/parquetUtils"; export default async function ExplorePage({ searchParams, @@ -10,7 +10,7 @@ export default async function ExplorePage({ searchParams: Promise<{ p?: string }>; }) { const params = await searchParams; - let datasets: any[] = []; + let datasets: { id: string }[] = []; let currentPage = 1; let totalPages = 1; try { @@ -40,7 +40,7 @@ export default async function ExplorePage({ // Fetch episode 0 data for each dataset const datasetWithVideos = ( await Promise.all( - datasets.map(async (ds: any) => { + datasets.map(async (ds) => { try { const [org, dataset] = ds.id.split("/"); const repoId = `${org}/${dataset}`; diff --git a/src/app/globals.css b/src/app/globals.css index b64eea66be356bca19e6d5c16e455ec88e68cd92..094a8539628c18d32fa3da0b0b3e67832ee43923 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -19,6 +19,11 @@ } } +html { + /* Scale all rem-based sizes (text, padding, buttons) up ~12% */ + font-size: 18px; +} + body { background: var(--background); color: var(--foreground); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 740ed31a86dc527b8e61f9e728b3af9e89e3bb92..79b9bc96cd5e10da10417b89e505998e3493c1a4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,8 +5,8 @@ import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "LeRobot Dataset Visualizer", - description: "Visualization of LeRobot Datasets", + title: "LeRobot Dataset Tool and Visualizer", + description: "Tool and Visualizer for LeRobot Datasets", }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 00f92b63c5ecd76bb3472d8a097c89ba03b22a67..840eb9fad71887e49c355747b48fc8a3fba6b974 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useSearchParams } from "next/navigation"; +declare global { + interface Window { + YT?: { + Player: new ( + id: string, + config: Record, + ) => { destroy?: () => void }; + }; + onYouTubeIframeAPIReady?: () => void; + } +} + export default function Home() { return ( @@ -53,18 +65,19 @@ function HomeInner() { } }, [searchParams, router]); - const playerRef = useRef(null); + const playerRef = useRef<{ destroy?: () => void } | null>(null); useEffect(() => { // Load YouTube IFrame API if not already present - if (!(window as any).YT) { + if (!window.YT) { const tag = document.createElement("script"); tag.src = "https://www.youtube.com/iframe_api"; document.body.appendChild(tag); } let interval: NodeJS.Timeout; - (window as any).onYouTubeIframeAPIReady = () => { - playerRef.current = new (window as any).YT.Player("yt-bg-player", { + window.onYouTubeIframeAPIReady = () => { + if (!window.YT) return; + playerRef.current = new window.YT.Player("yt-bg-player", { videoId: "Er8SPJsIYr0", playerVars: { autoplay: 1, @@ -79,7 +92,14 @@ function HomeInner() { start: 0, }, events: { - onReady: (event: any) => { + onReady: (event: { + target: { + playVideo: () => void; + mute: () => void; + seekTo: (t: number) => void; + getCurrentTime: () => number; + }; + }) => { event.target.playVideo(); event.target.mute(); interval = setInterval(() => { @@ -101,7 +121,7 @@ function HomeInner() { const inputRef = useRef(null); - const handleGo = (e: React.FormEvent) => { + const handleGo = (e: { preventDefault: () => void }) => { e.preventDefault(); const value = inputRef.current?.value.trim(); if (value) { @@ -120,16 +140,8 @@ function HomeInner() { {/* Centered Content */}

- LeRobot Dataset Visualizer + LeRobot Dataset Tool and Visualizer

- - create & train your own robots -
{ if (e.key === "Enter") { - // Prevent double submission if form onSubmit also fires e.preventDefault(); - handleGo(e as any); + handleGo(e); } }} /> diff --git a/src/components/action-insights-panel.tsx b/src/components/action-insights-panel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a7af27a304e3b6922e2df135b0a92fd3397ba07 --- /dev/null +++ b/src/components/action-insights-panel.tsx @@ -0,0 +1,1672 @@ +"use client"; + +import React, { useMemo, useState, useEffect } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { useFlaggedEpisodes } from "@/context/flagged-episodes-context"; +import { CHART_CONFIG } from "@/utils/constants"; +import type { + CrossEpisodeVarianceData, + AggVelocityStat, + AggAutocorrelation, + SpeedDistEntry, + JerkyEpisode, + AggAlignment, +} from "@/app/[org]/[dataset]/[episode]/fetch-data"; + +const FullscreenCtx = React.createContext(false); +const useIsFullscreen = () => React.useContext(FullscreenCtx); + +function InfoToggle({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open &&
{children}
} + + ); +} + +function FullscreenWrapper({ children }: { children: React.ReactNode }) { + const [fs, setFs] = useState(false); + + useEffect(() => { + if (!fs) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setFs(false); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [fs]); + + return ( +
+ + {fs ? ( +
+ +
+ + {children} + +
+
+ ) : ( + children + )} +
+ ); +} + +function FlagBtn({ id }: { id: number }) { + const { has, toggle } = useFlaggedEpisodes(); + const flagged = has(id); + return ( + + ); +} + +function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) { + const { addMany } = useFlaggedEpisodes(); + return ( + + ); +} +const COLORS = [ + "#f97316", + "#3b82f6", + "#22c55e", + "#ef4444", + "#a855f7", + "#eab308", + "#06b6d4", + "#ec4899", + "#14b8a6", + "#f59e0b", + "#6366f1", + "#84cc16", +]; + +function shortName(key: string): string { + const parts = key.split(CHART_CONFIG.SERIES_NAME_DELIMITER); + return parts.length > 1 ? parts[parts.length - 1] : key; +} + +function getActionKeys(row: Record): string[] { + return Object.keys(row) + .filter((k) => k.startsWith("action") && k !== "timestamp") + .sort(); +} + +function getStateKeys(row: Record): string[] { + return Object.keys(row) + .filter( + (k) => + k.includes("state") && k !== "timestamp" && !k.startsWith("action"), + ) + .sort(); +} + +// ─── Autocorrelation ───────────────────────────────────────────── + +function computeAutocorrelation(values: number[], maxLag: number): number[] { + const n = values.length; + const mean = values.reduce((a, b) => a + b, 0) / n; + const centered = values.map((v) => v - mean); + const variance = centered.reduce((a, v) => a + v * v, 0); + if (variance === 0) return Array(maxLag).fill(0); + + const result: number[] = []; + for (let lag = 1; lag <= maxLag; lag++) { + let sum = 0; + for (let t = 0; t < n - lag; t++) sum += centered[t] * centered[t + lag]; + result.push(sum / variance); + } + return result; +} + +function findDecorrelationLag(acf: number[], threshold = 0.5): number | null { + const idx = acf.findIndex((v) => v < threshold); + return idx >= 0 ? idx + 1 : null; +} + +function AutocorrelationSection({ + data, + fps, + agg, + numEpisodes, +}: { + data: Record[]; + fps: number; + agg?: AggAutocorrelation | null; + numEpisodes?: number; +}) { + const isFs = useIsFullscreen(); + const actionKeys = useMemo( + () => (data.length > 0 ? getActionKeys(data[0]) : []), + [data], + ); + const maxLag = useMemo( + () => Math.min(Math.floor(data.length / 2), 100), + [data], + ); + + const fallback = useMemo(() => { + if (agg) return null; + if (actionKeys.length === 0 || maxLag < 2) + return { chartData: [], suggestedChunk: null, shortKeys: [] as string[] }; + + const acfs = actionKeys.map((key) => { + const values = data.map((row) => row[key] ?? 0); + return computeAutocorrelation(values, maxLag); + }); + + const rows = Array.from({ length: maxLag }, (_, lag) => { + const row: Record = { + lag: lag + 1, + time: (lag + 1) / fps, + }; + actionKeys.forEach((key, ki) => { + row[shortName(key)] = acfs[ki][lag]; + }); + return row; + }); + + const lags = acfs + .map((acf) => findDecorrelationLag(acf, 0.5)) + .filter(Boolean) as number[]; + const suggested = + lags.length > 0 + ? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)] + : null; + + return { + chartData: rows, + suggestedChunk: suggested, + shortKeys: actionKeys.map(shortName), + }; + }, [data, actionKeys, maxLag, fps, agg]); + + const { chartData, suggestedChunk, shortKeys } = agg ?? + fallback ?? { chartData: [], suggestedChunk: null, shortKeys: [] }; + const isAgg = !!agg; + const numEpisodesLabel = isAgg + ? ` (${numEpisodes} episodes sampled)` + : " (current episode)"; + + const yDomain = useMemo(() => { + if (chartData.length === 0 || shortKeys.length === 0) + return [-0.2, 1] as [number, number]; + let min = Infinity; + for (const row of chartData) + for (const k of shortKeys) { + const v = row[k]; + if (typeof v === "number" && v < min) min = v; + } + const lo = Math.floor(Math.min(min, 0) * 10) / 10; + return [lo, 1] as [number, number]; + }, [chartData, shortKeys]); + + if (shortKeys.length === 0) + return

No action columns found.

; + + return ( +
+
+
+

+ Action Autocorrelation + + {numEpisodesLabel} + +

+ +

+ Shows how correlated each action dimension is with itself over + increasing time lags. Where autocorrelation drops below 0.5 + suggests a{" "} + + natural action chunk boundary + {" "} + — actions beyond this lag are essentially independent, so + executing them open-loop offers diminishing returns. +
+ + Grounded in the theoretical result that chunk length should + scale logarithmically with system stability constants ( + + Zhang et al., 2025 + + , Theorem 1). + +

+
+
+
+ + {suggestedChunk && ( +
+ + {suggestedChunk} + +
+

+ Suggested chunk length: {suggestedChunk} steps ( + {(suggestedChunk / fps).toFixed(2)}s) +

+

+ Median lag where autocorrelation drops below 0.5 across action + dimensions +

+
+
+ )} + +
+ + + + + Number(v.toFixed(2)).toString()} + /> + + `Lag ${v} (${(Number(v) / fps).toFixed(2)}s)` + } + formatter={(v: number) => v.toFixed(3)} + /> + 0.5} + stroke="#64748b" + strokeDasharray="6 4" + dot={false} + name="0.5 threshold" + legendType="none" + isAnimationActive={false} + /> + {shortKeys.map((name, i) => ( + + ))} + + +
+ + {/* Custom legend */} +
+ {shortKeys.map((name, i) => ( +
+ + {name} +
+ ))} +
+
+ ); +} + +// ─── Action Velocity ───────────────────────────────────────────── + +function ActionVelocitySection({ + data, + agg, + numEpisodes, + jerkyEpisodes, +}: { + data: Record[]; + agg?: AggVelocityStat[]; + numEpisodes?: number; + jerkyEpisodes?: JerkyEpisode[]; +}) { + const actionKeys = useMemo( + () => (data.length > 0 ? getActionKeys(data[0]) : []), + [data], + ); + + const fallbackStats = useMemo(() => { + if (agg && agg.length > 0) return null; + if (actionKeys.length === 0 || data.length < 2) return []; + + const ACTIVITY_THRESHOLD = 0.001; // 0.1% of motor range + const DISCRETE_THRESHOLD = 4; // ≤ this many unique values → discrete + return actionKeys.map((key) => { + const values = data.map((row) => row[key] ?? 0); + const motorMin = Math.min(...values); + const motorMax = Math.max(...values); + const motorRange = motorMax - motorMin || 1; + const uniqueVals = new Set(values); + const nUnique = uniqueVals.size; + const discrete = nUnique <= DISCRETE_THRESHOLD; + const deltas = values.slice(1).map((v, i) => v - values[i]); + if (deltas.length === 0) + return { + name: shortName(key), + std: 0, + maxAbs: 0, + bins: new Array(30).fill(0), + lo: 0, + hi: 0, + motorRange, + discrete, + }; + + // Activity score: p95 of |Δa| + const absDeltas = deltas.map(Math.abs).sort((a, b) => a - b); + const p95 = absDeltas[Math.floor(absDeltas.length * 0.95)]; + const inactive = p95 < motorRange * ACTIVITY_THRESHOLD; + + const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length; + const rawStd = Math.sqrt( + deltas.reduce((a, d) => a + (d - mean) ** 2, 0) / deltas.length, + ); + const std = rawStd / motorRange; + const maxAbsRaw = Math.max(...absDeltas); + const maxAbs = maxAbsRaw / motorRange; + + const binCount = 30; + const lo = Math.min(...deltas) / motorRange; + const hi = Math.max(...deltas) / motorRange; + const range = hi - lo || 1; + const binW = range / binCount; + const bins: number[] = new Array(binCount).fill(0); + for (const d of deltas) { + const normD = d / motorRange; + let b = Math.floor((normD - lo) / binW); + if (b >= binCount) b = binCount - 1; + bins[b]++; + } + return { + name: shortName(key), + std, + maxAbs, + bins, + lo, + hi, + motorRange, + inactive, + discrete, + }; + }); + }, [data, actionKeys, agg]); + + const stats = useMemo( + () => (agg && agg.length > 0 ? agg : (fallbackStats ?? [])), + [agg, fallbackStats], + ); + const isAgg = agg && agg.length > 0; + + const maxBinCount = useMemo( + () => (stats.length > 0 ? Math.max(...stats.flatMap((s) => s.bins)) : 0), + [stats], + ); + const maxStd = useMemo(() => { + const active = stats.filter((s) => !s.inactive && !s.discrete); + return active.length > 0 ? Math.max(...active.map((s) => s.std)) : 1; + }, [stats]); + + const insight = useMemo(() => { + if (stats.length === 0) return null; + const active = stats.filter((s) => !s.inactive && !s.discrete); + const excluded = stats.filter((s) => s.inactive || s.discrete); + const smooth = active.filter((s) => s.std / maxStd < 0.4); + const moderate = active.filter( + (s) => s.std / maxStd >= 0.4 && s.std / maxStd < 0.7, + ); + const jerky = active.filter((s) => s.std / maxStd >= 0.7); + const isGripper = (n: string) => /grip/i.test(n); + const jerkyNonGripper = jerky.filter((s) => !isGripper(s.name)); + const jerkyGripper = jerky.filter((s) => isGripper(s.name)); + + let verdict: { label: string; color: string }; + if (active.length === 0) { + verdict = { label: "N/A", color: "text-zinc-400" }; + } else { + const smoothRatio = smooth.length / active.length; + if (smoothRatio >= 0.6 && jerkyNonGripper.length === 0) + verdict = { label: "Smooth", color: "text-green-400" }; + else if (jerkyNonGripper.length <= 2 && smoothRatio >= 0.3) + verdict = { label: "Moderate", color: "text-yellow-400" }; + else verdict = { label: "Jerky", color: "text-red-400" }; + } + + const lines: string[] = []; + if (smooth.length > 0) + lines.push( + `${smooth.length} smooth (${smooth.map((s) => s.name).join(", ")})`, + ); + if (moderate.length > 0) + lines.push( + `${moderate.length} moderate (${moderate.map((s) => s.name).join(", ")})`, + ); + if (jerkyNonGripper.length > 0) + lines.push( + `${jerkyNonGripper.length} jerky (${jerkyNonGripper.map((s) => s.name).join(", ")})`, + ); + if (jerkyGripper.length > 0) + lines.push( + `${jerkyGripper.length} gripper${jerkyGripper.length > 1 ? "s" : ""} jerky — expected for binary open/close`, + ); + if (excluded.length > 0) { + const discreteOnly = excluded.filter((s) => s.discrete); + const inactiveOnly = excluded.filter((s) => s.inactive && !s.discrete); + const parts: string[] = []; + if (discreteOnly.length > 0) + parts.push( + `${discreteOnly.length} discrete (${discreteOnly.map((s) => s.name).join(", ")})`, + ); + if (inactiveOnly.length > 0) + parts.push( + `${inactiveOnly.length} inactive (${inactiveOnly.map((s) => s.name).join(", ")})`, + ); + lines.push(`${parts.join("; ")} — excluded from verdict`); + } + + let tip: string; + if (verdict.label === "N/A") + tip = "All motors are inactive or discrete — no motors to evaluate."; + else if (verdict.label === "Smooth") + tip = "Actions are consistent — longer action chunks should work well."; + else if (verdict.label === "Moderate") + tip = + "Some dimensions show abrupt changes. Consider moderate chunk sizes."; + else + tip = + "Many dimensions are jerky. Use shorter action chunks and consider filtering outlier episodes."; + + return { verdict, lines, tip }; + }, [stats, maxStd]); + + if (stats.length === 0) + return ( +

+ No action data for velocity analysis. +

+ ); + + return ( +
+
+
+

+ Action Velocity (Δa) — Smoothness Proxy + + {isAgg + ? `(${numEpisodes} episodes sampled)` + : "(current episode)"} + +

+ +

+ Shows the distribution of frame-to-frame action changes (Δa = a + t+1 − at) for each dimension. A{" "} + + tight distribution around zero + {" "} + means smooth, predictable control — the system is likely stable + and benefits from longer action chunks. + Fat tails or high std{" "} + indicate jerky demonstrations, suggesting shorter chunks and + potentially beneficial noise injection. +
+ + Relates to the Lipschitz constant Lπ and smoothness C + π in{" "} + + Zhang et al. (2025) + + , which govern compounding error bounds (Assumptions 3.1, 4.1). + +

+
+
+
+ + {/* Per-dimension mini histograms + stats */} +
+ {stats.map((s, si) => { + const barH = 28; + const dimmed = !!s.inactive || !!s.discrete; + const tag = + s.inactive && s.discrete + ? "inactive & discrete" + : s.discrete + ? "discrete" + : s.inactive + ? "inactive" + : null; + return ( +
+

+ {s.name} + {tag && ( + + ({tag}) + + )} +

+
+ σ={s.std.toFixed(4)} + + |Δ|max={s.maxAbs.toFixed(4)} + +
+ + {[...s.bins].map((count, bi) => { + const h = maxBinCount > 0 ? (count / maxBinCount) * barH : 0; + return ( + + ); + })} + +
+
+
+
+ ); + })} +
+ + {insight && ( +
+

+ Overall:{" "} + + {insight.verdict.label} + +

+
    + {insight.lines.map((l, i) => ( +
  • {l}
  • + ))} +
+

{insight.tip}

+
+ )} + + {jerkyEpisodes && jerkyEpisodes.length > 0 && ( + + )} +
+ ); +} + +function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) { + const [showAll, setShowAll] = useState(false); + const display = showAll ? episodes : episodes.slice(0, 15); + + return ( +
+
+

+ Most Jerky Episodes{" "} + + sorted by mean |Δa| + +

+
+ e.episodeIndex)} /> + {episodes.length > 15 && ( + + )} +
+
+
+ + + + + + + + + {display.map((e) => ( + + + + + + ))} + +
+ EpisodeMean |Δa|
+ + ep {e.episodeIndex} + {e.meanAbsDelta.toFixed(4)} +
+
+
+ ); +} + +// ─── Cross-Episode Variance Heatmap ────────────────────────────── + +function VarianceHeatmap({ + data, + loading, +}: { + data: CrossEpisodeVarianceData | null; + loading: boolean; +}) { + const isFs = useIsFullscreen(); + + if (loading) { + return ( +
+

+ Cross-Episode Action Variance +

+
+ + + + + Loading cross-episode data (sampled up to 500 episodes)… +
+
+ ); + } + + if (!data) { + return ( +
+

+ Cross-Episode Action Variance +

+

+ Not enough episodes or no action data to compute variance. +

+
+ ); + } + const { actionNames, timeBins, variance, numEpisodes } = data; + const numDims = actionNames.length; + const numBins = timeBins.length; + + const maxVar = Math.max(...variance.flat(), 1e-10); + + const baseW = isFs ? 1000 : 560; + const baseH = isFs ? 500 : 300; + const cellW = Math.max( + 6, + Math.min(isFs ? 24 : 14, Math.floor(baseW / numBins)), + ); + const cellH = Math.max( + 20, + Math.min(isFs ? 56 : 36, Math.floor(baseH / numDims)), + ); + const labelW = 100; + const svgW = labelW + numBins * cellW + 60; + const svgH = numDims * cellH + 40; + + function varColor(v: number): string { + const t = Math.sqrt(v / maxVar); // sqrt for better visual spread + // Dark blue → teal → orange + const r = Math.round(t * 249); + const g = Math.round(t < 0.5 ? 80 + t * 200 : 180 - (t - 0.5) * 200); + const b = Math.round((1 - t) * 200 + 30); + return `rgb(${r},${g},${b})`; + } + + return ( +
+
+
+

+ Cross-Episode Action Variance + + ({numEpisodes} episodes sampled) + +

+ +

+ Shows how much each action dimension varies across episodes at + each point in time (normalized 0–100%). + + {" "} + High-variance regions + {" "} + indicate multi-modal or inconsistent demonstrations — generative + policies (diffusion, flow-matching) and action chunking help here + by modeling multiple modes. + Low-variance regions{" "} + indicate consistent behavior across demonstrations. +
+ + Relates to the "coverage" discussion in{" "} + + Zhang et al. (2025) + {" "} + — regions with low variance may lack the exploratory coverage + needed to prevent compounding errors (Section 4). + +

+
+
+
+ +
+ + {/* Heatmap cells */} + {variance.map((row, bi) => + row.map((v, di) => ( + + {`${shortName(actionNames[di])} @ ${(timeBins[bi] * 100).toFixed(0)}%: var=${v.toFixed(5)}`} + + )), + )} + + {/* Y-axis: action names */} + {actionNames.map((name, di) => ( + + {shortName(name)} + + ))} + + {/* X-axis labels */} + {[0, 0.25, 0.5, 0.75, 1].map((frac) => { + const binIdx = Math.round(frac * (numBins - 1)); + return ( + + {(frac * 100).toFixed(0)}% + + ); + })} + + Episode progress + + + {/* Color bar */} + {Array.from({ length: 10 }, (_, i) => { + const t = i / 9; + const barX = labelW + numBins * cellW + 16; + const barH = (numDims * cellH) / 10; + return ( + + ); + })} + + high + + + low + + +
+
+ ); +} + +// ─── Demonstrator Speed Variance ──────────────────────────────── + +function SpeedVarianceSection({ + distribution, + numEpisodes, +}: { + distribution: SpeedDistEntry[]; + numEpisodes: number; +}) { + const isFs = useIsFullscreen(); + const { speeds, mean, std, cv, median, bins, lo, binW, maxBin, verdict } = + useMemo(() => { + const sp = distribution.map((d) => d.speed).sort((a, b) => a - b); + const m = sp.reduce((a, b) => a + b, 0) / sp.length; + const s = Math.sqrt(sp.reduce((a, v) => a + (v - m) ** 2, 0) / sp.length); + const c = m > 0 ? s / m : 0; + const med = sp[Math.floor(sp.length / 2)]; + + const binCount = Math.min(30, Math.ceil(Math.sqrt(sp.length))); + const lo = sp[0], + hi = sp[sp.length - 1]; + const bw = (hi - lo || 1) / binCount; + const b = new Array(binCount).fill(0); + for (const v of sp) { + let i = Math.floor((v - lo) / bw); + if (i >= binCount) i = binCount - 1; + b[i]++; + } + + let v: { label: string; color: string; tip: string }; + if (c < 0.2) + v = { + label: "Consistent", + color: "text-green-400", + tip: "Demonstrators execute at similar speeds — no velocity normalization needed.", + }; + else if (c < 0.4) + v = { + label: "Moderate variance", + color: "text-yellow-400", + tip: "Some speed variation across demonstrators. Consider velocity normalization for best results.", + }; + else + v = { + label: "High variance", + color: "text-red-400", + tip: "Large speed differences between demonstrations. Velocity normalization before training is strongly recommended.", + }; + + return { + speeds: sp, + mean: m, + std: s, + cv: c, + median: med, + bins: b, + lo, + binW: bw, + maxBin: Math.max(...b), + verdict: v, + }; + }, [distribution]); + + if (speeds.length < 3) return null; + + const barH = isFs ? 250 : 100; + const barW = Math.max(8, Math.floor((isFs ? 900 : 500) / bins.length)); + + return ( +
+
+
+

+ Demonstrator Speed Variance + + ({numEpisodes} episodes) + +

+ +

+ Distribution of average execution speed (mean ‖Δat‖ per + frame) across all episodes. Different human demonstrators often + execute at{" "} + different speeds, + creating artificial multimodality in the action distribution that + confuses the policy. A coefficient of variation (CV) above 0.3 + strongly suggests normalizing trajectory speed before training. +
+ + Based on "Is Diversity All You Need" (AGI-Bot, 2025) + which shows velocity normalization dramatically improves + fine-tuning success rate. + +

+
+
+
+ +
+
+ + {bins.map((count: number, i: number) => { + const h = maxBin > 0 ? (count / maxBin) * barH : 0; + const speed = lo + (i + 0.5) * binW; + const ratio = median > 0 ? speed / median : 1; + const dev = Math.abs(ratio - 1); + const color = + dev < 0.2 ? "#22c55e" : dev < 0.5 ? "#eab308" : "#ef4444"; + return ( + + {`Speed ${(lo + i * binW).toFixed(3)}–${(lo + (i + 1) * binW).toFixed(3)}: ${count} ep (${ratio.toFixed(2)}× median)`} + + ); + })} + {[0, 0.25, 0.5, 0.75, 1].map((frac) => { + const idx = Math.round(frac * (bins.length - 1)); + return ( + + {(lo + idx * binW).toFixed(2)} + + ); + })} + +
+
+
+ Mean{" "} + + {mean.toFixed(4)} + +
+
+ Median{" "} + + {median.toFixed(4)} + +
+
+ Std{" "} + + {std.toFixed(4)} + +
+
+ CV + + {cv.toFixed(3)} + +
+
+
+ +
+

+ Verdict: {verdict.label} +

+

{verdict.tip}

+
+
+ ); +} + +// ─── State–Action Temporal Alignment ──────────────────────────── + +function StateActionAlignmentSection({ + data, + fps, + agg, + numEpisodes, +}: { + data: Record[]; + fps: number; + agg?: AggAlignment | null; + numEpisodes?: number; +}) { + const isFs = useIsFullscreen(); + const result = useMemo(() => { + if (agg) return { ...agg, fromAgg: true }; + if (data.length < 10) return null; + const actionKeys = getActionKeys(data[0]); + const stateKeys = getStateKeys(data[0]); + if (actionKeys.length === 0 || stateKeys.length === 0) return null; + const maxLag = Math.min(Math.floor(data.length / 4), 30); + if (maxLag < 2) return null; + + // Match action↔state by suffix, fall back to index matching + const pairs: [string, string][] = []; + for (const aKey of actionKeys) { + const match = stateKeys.find( + (sKey) => shortName(sKey) === shortName(aKey), + ); + if (match) pairs.push([aKey, match]); + } + if (pairs.length === 0) { + const count = Math.min(actionKeys.length, stateKeys.length); + for (let i = 0; i < count; i++) pairs.push([actionKeys[i], stateKeys[i]]); + } + if (pairs.length === 0) return null; + + // Per-pair cross-correlation + const pairCorrs: number[][] = []; + for (const [aKey, sKey] of pairs) { + const aVals = data.map((row) => row[aKey] ?? 0); + const sDeltas = data + .slice(1) + .map((row, i) => (row[sKey] ?? 0) - (data[i][sKey] ?? 0)); + const n = Math.min(aVals.length, sDeltas.length); + const aM = aVals.slice(0, n).reduce((a, b) => a + b, 0) / n; + const sM = sDeltas.slice(0, n).reduce((a, b) => a + b, 0) / n; + + const corrs: number[] = []; + for (let lag = -maxLag; lag <= maxLag; lag++) { + let sum = 0, + aV = 0, + sV = 0; + for (let t = 0; t < n; t++) { + const sIdx = t + lag; + if (sIdx < 0 || sIdx >= sDeltas.length) continue; + const a = aVals[t] - aM, + s = sDeltas[sIdx] - sM; + sum += a * s; + aV += a * a; + sV += s * s; + } + const d = Math.sqrt(aV * sV); + corrs.push(d > 0 ? sum / d : 0); + } + pairCorrs.push(corrs); + } + + // Aggregate min/mean/max per lag + const ccData = Array.from({ length: 2 * maxLag + 1 }, (_, li) => { + const lag = -maxLag + li; + const vals = pairCorrs.map((pc) => pc[li]); + return { + lag, + time: lag / fps, + max: Math.max(...vals), + mean: vals.reduce((a, b) => a + b, 0) / vals.length, + min: Math.min(...vals), + }; + }); + + // Peaks of the envelope curves + let meanPeakLag = 0, + meanPeakCorr = -Infinity; + let maxPeakLag = 0, + maxPeakCorr = -Infinity; + let minPeakLag = 0, + minPeakCorr = -Infinity; + for (const row of ccData) { + if (row.max > maxPeakCorr) { + maxPeakCorr = row.max; + maxPeakLag = row.lag; + } + if (row.mean > meanPeakCorr) { + meanPeakCorr = row.mean; + meanPeakLag = row.lag; + } + if (row.min > minPeakCorr) { + minPeakCorr = row.min; + minPeakLag = row.lag; + } + } + + // Per-pair individual peak lags (for showing the true range across dimensions) + const perPairPeakLags = pairCorrs.map((pc) => { + let best = -Infinity, + bestLag = 0; + for (let li = 0; li < pc.length; li++) { + if (pc[li] > best) { + best = pc[li]; + bestLag = -maxLag + li; + } + } + return bestLag; + }); + const lagRangeMin = Math.min(...perPairPeakLags); + const lagRangeMax = Math.max(...perPairPeakLags); + + return { + ccData, + meanPeakLag, + meanPeakCorr, + maxPeakLag, + maxPeakCorr, + minPeakLag, + minPeakCorr, + lagRangeMin, + lagRangeMax, + numPairs: pairs.length, + fromAgg: false, + }; + }, [data, fps, agg]); + + if (!result) return null; + const { + ccData, + meanPeakLag, + meanPeakCorr, + maxPeakLag, + maxPeakCorr, + minPeakLag, + minPeakCorr, + lagRangeMin, + lagRangeMax, + numPairs, + fromAgg, + } = result; + const scopeLabel = fromAgg + ? `${numEpisodes} episodes sampled` + : "current episode"; + + return ( +
+
+
+

+ State–Action Temporal Alignment + + ({scopeLabel}, {numPairs} matched pair{numPairs !== 1 ? "s" : ""}) + +

+ +

+ Per-dimension cross-correlation between actiond(t) and + Δstated(t+lag), aggregated as + max,{" "} + mean, and + min across all matched + action–state pairs. The{" "} + peak lag reveals the + effective control delay — the time between when an action is + commanded and when the corresponding state changes. +
+ + Central to ACT ( + + Zhao et al., 2023 + {" "} + — action chunking compensates for delay), Real-Time Chunking + (RTC,{" "} + + Black et al., 2025 + + ), and Training-Time RTC ( + + Black et al., 2025 + + ) — all address the timing mismatch between commanded actions + and observed state changes. + +

+
+
+
+ + {meanPeakLag !== 0 && ( +
+ + {meanPeakLag} + +
+

+ Mean control delay: {meanPeakLag} step + {Math.abs(meanPeakLag) !== 1 ? "s" : ""} ( + {(meanPeakLag / fps).toFixed(3)}s) +

+

+ {meanPeakLag > 0 + ? `State changes lag behind actions by ~${meanPeakLag} frames on average. Consider aligning action[t] with state[t+${meanPeakLag}].` + : `Actions lag behind state changes by ~${-meanPeakLag} frames on average (predictive actions).`} + {lagRangeMin !== lagRangeMax && + ` Individual dimension peaks range from ${lagRangeMin} to ${lagRangeMax} steps.`} +

+
+
+ )} + +
+ + + + + Number(v.toFixed(2)).toString()} + /> + + `Lag ${v} (${(Number(v) / fps).toFixed(3)}s)` + } + formatter={(v: number) => v.toFixed(3)} + /> + + + + 0} + stroke="#64748b" + strokeDasharray="6 4" + dot={false} + name="zero" + legendType="none" + isAnimationActive={false} + /> + + +
+ +
+
+ + + max (peak: lag {maxPeakLag}, r={maxPeakCorr.toFixed(3)}) + +
+
+ + + mean (peak: lag {meanPeakLag}, r={meanPeakCorr.toFixed(3)}) + +
+
+ + + min (peak: lag {minPeakLag}, r={minPeakCorr.toFixed(3)}) + +
+
+ + {meanPeakLag === 0 && ( +

+ Mean peak correlation at lag 0 (r={meanPeakCorr.toFixed(3)}) — actions + and state changes are well-aligned in this episode. +

+ )} +
+ ); +} + +// ─── Main Panel ────────────────────────────────────────────────── + +interface ActionInsightsPanelProps { + flatChartData: Record[]; + fps: number; + crossEpisodeData: CrossEpisodeVarianceData | null; + crossEpisodeLoading: boolean; +} + +function ActionInsightsPanel({ + flatChartData, + fps, + crossEpisodeData, + crossEpisodeLoading, +}: ActionInsightsPanelProps) { + const [mode, setMode] = useState<"episode" | "dataset">("dataset"); + const showAgg = mode === "dataset" && !!crossEpisodeData; + + return ( +
+
+
+

Action Insights

+

+ Data-driven analysis to guide action chunking, data quality + assessment, and training configuration. +

+
+
+ + Current Episode + + + + All Episodes + {crossEpisodeData ? ` (${crossEpisodeData.numEpisodes})` : ""} + +
+
+ + + + + + + + + {crossEpisodeData?.speedDistribution && + crossEpisodeData.speedDistribution.length > 2 && ( + + + + )} + + + +
+ ); +} + +export default ActionInsightsPanel; +export { ActionVelocitySection, FullscreenWrapper }; diff --git a/src/components/data-recharts.tsx b/src/components/data-recharts.tsx index 412590a72151eeb8436cf04eda19c2bfe4478e1d..220a25ce6cdce2a3e1c45abdbd5e19e55a963179 100644 --- a/src/components/data-recharts.tsx +++ b/src/components/data-recharts.tsx @@ -10,53 +10,136 @@ import { CartesianGrid, ResponsiveContainer, Tooltip, + ReferenceLine, } from "recharts"; -import type { ChartDataGroup } from "@/types"; -// Recharts event payload types -interface ChartPayload { - timestamp: number; - [key: string]: number | Record; -} - -interface ChartEventData { - activePayload?: Array<{ payload: ChartPayload }>; - activeLabel?: string | number; -} +type ChartRow = Record>; type DataGraphProps = { - data: ChartDataGroup[]; + data: ChartRow[][]; onChartsReady?: () => void; }; import React, { useMemo } from "react"; -// Use the same delimiter as the data processing const SERIES_NAME_DELIMITER = " | "; +const CHART_COLORS = [ + "#f97316", + "#3b82f6", + "#22c55e", + "#ef4444", + "#a855f7", + "#eab308", + "#06b6d4", + "#ec4899", + "#14b8a6", + "#f59e0b", + "#6366f1", + "#84cc16", +]; + +function mergeGroups(data: ChartRow[][]): ChartRow[] { + if (data.length <= 1) return data[0] ?? []; + const maxLen = Math.max(...data.map((g) => g.length)); + const merged: ChartRow[] = []; + for (let i = 0; i < maxLen; i++) { + const row: ChartRow = {}; + for (const group of data) { + const src = group[i]; + if (!src) continue; + for (const [k, v] of Object.entries(src)) { + if (k === "timestamp") { + row[k] = v; + continue; + } + row[k] = v; + } + } + merged.push(row); + } + return merged; +} + export const DataRecharts = React.memo( ({ data, onChartsReady }: DataGraphProps) => { - // Shared hoveredTime for all graphs const [hoveredTime, setHoveredTime] = useState(null); + const [expanded, setExpanded] = useState(false); useEffect(() => { - if (typeof onChartsReady === "function") { - onChartsReady(); - } + if (typeof onChartsReady === "function") onChartsReady(); }, [onChartsReady]); + const combinedData = useMemo( + () => (expanded ? mergeGroups(data) : []), + [data, expanded], + ); + if (!Array.isArray(data) || data.length === 0) return null; return ( -
- {data.map((group, idx) => ( +
+ {data.length > 1 && ( +
+ +
+ )} + + {expanded ? ( - ))} + ) : ( +
+ {data.map((group, idx) => ( + + ))} +
+ )}
); }, @@ -67,10 +150,12 @@ const SingleDataGraph = React.memo( data, hoveredTime, setHoveredTime, + tall, }: { - data: ChartDataGroup; + data: ChartRow[]; hoveredTime: number | null; setHoveredTime: (t: number | null) => void; + tall?: boolean; }) => { const { currentTime, setCurrentTime } = useTime(); function flattenRow( @@ -101,9 +186,8 @@ const SingleDataGraph = React.memo( ); } } - // Always keep timestamp at top level if present - if ("timestamp" in row && typeof row.timestamp === "number") { - result.timestamp = row.timestamp; + if ("timestamp" in row && typeof row["timestamp"] === "number") { + result["timestamp"] = row["timestamp"]; } return result; } @@ -135,12 +219,10 @@ const SingleDataGraph = React.memo( } }); - // Assign a color per group (and for singles) const allGroups = [...Object.keys(groups), ...singles]; const groupColorMap: Record = {}; allGroups.forEach((group, idx) => { - groupColorMap[group] = - `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`; + groupColorMap[group] = CHART_COLORS[idx % CHART_COLORS.length]; }); // Find the closest data point to the current time for highlighting @@ -157,10 +239,11 @@ const SingleDataGraph = React.memo( setHoveredTime(null); }; - const handleClick = (data: ChartEventData) => { - if (data?.activePayload?.[0]) { - const timeValue = data.activePayload[0].payload.timestamp; - setCurrentTime(timeValue); + const handleClick = ( + data: { activePayload?: { payload: { timestamp: number } }[] } | null, + ) => { + if (data?.activePayload?.length) { + setCurrentTime(data.activePayload[0].payload.timestamp); } }; @@ -185,12 +268,10 @@ const SingleDataGraph = React.memo( } }); - // Assign a color per group (and for singles) const allGroups = [...Object.keys(groups), ...singles]; const groupColorMap: Record = {}; allGroups.forEach((group, idx) => { - groupColorMap[group] = - `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`; + groupColorMap[group] = CHART_COLORS[idx % CHART_COLORS.length]; }); const isGroupChecked = (group: string) => @@ -220,13 +301,12 @@ const SingleDataGraph = React.memo( }; return ( -
- {/* Grouped keys */} +
{Object.entries(groups).map(([group, children]) => { const color = groupColorMap[group]; return ( -
-