mishig HF Staff commited on
Commit
8a37195
·
1 Parent(s): 64d780c

Added files from Mishig PR

Browse files

Co-authored-by: Mishig <dmishig@gmail.com>

.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+ /public
20
+
21
+ # production
22
+ /build
23
+
24
+ # misc
25
+ .DS_Store
26
+ *.pem
27
+
28
+ # debug
29
+ npm-debug.log*
30
+ yarn-debug.log*
31
+ yarn-error.log*
32
+ .pnpm-debug.log*
33
+
34
+ # env files (can opt-in for committing if needed)
35
+ .env*
36
+
37
+ # vercel
38
+ .vercel
39
+
40
+ # typescript
41
+ *.tsbuildinfo
42
+ next-env.d.ts
README.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LeRobot Dataset Visualizer
2
+
3
+ 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.
4
+
5
+ ## Project Overview
6
+
7
+ This tool is designed to help robotics researchers and practitioners quickly inspect and understand large, complex datasets. It fetches dataset metadata and episode data (including video and sensor/telemetry data), and provides a unified interface for:
8
+
9
+ - Navigating between organizations, datasets, and episodes
10
+ - Watching episode videos
11
+ - Exploring synchronized time-series data with interactive charts
12
+ - Paginating through large datasets efficiently
13
+
14
+ ## Key Features
15
+
16
+ - **Dataset & Episode Navigation:** Quickly jump between organizations, datasets, and episodes using a sidebar and navigation controls.
17
+ - **Synchronized Video & Data:** Video playback is synchronized with interactive data graphs for detailed inspection of sensor and control signals.
18
+ - **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination and chunking.
19
+ - **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
20
+
21
+ ## Technologies Used
22
+
23
+ - **Next.js** (App Router)
24
+ - **React**
25
+ - **Recharts** (for data visualization)
26
+ - **hyparquet** (for reading Parquet files)
27
+ - **Tailwind CSS** (styling)
28
+
29
+ ## Getting Started
30
+
31
+ First, run the development server:
32
+
33
+ ```bash
34
+ npm run dev
35
+ # or
36
+ yarn dev
37
+ # or
38
+ pnpm dev
39
+ # or
40
+ bun dev
41
+ ```
42
+
43
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
44
+
45
+ You can start editing the page by modifying `src/app/page.tsx` or other files in the `src/` directory. The app supports hot-reloading for rapid development.
46
+
47
+ ### Environment Variables
48
+
49
+ - `DATASET_URL`: (optional) Base URL for dataset hosting (defaults to HuggingFace Datasets).
50
+
51
+ ## Contributing
52
+
53
+ Contributions, bug reports, and feature requests are welcome! Please open an issue or submit a pull request.
54
+
55
+ ## License
56
+
57
+ This project is licensed under the MIT License.
eslint.config.mjs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { dirname } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { FlatCompat } from "@eslint/eslintrc";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ const compat = new FlatCompat({
9
+ baseDirectory: __dirname,
10
+ });
11
+
12
+ const eslintConfig = [
13
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
14
+ ];
15
+
16
+ export default eslintConfig;
next.config.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+ import packageJson from './package.json';
3
+
4
+ const nextConfig: NextConfig = {
5
+
6
+ typescript: {
7
+ ignoreBuildErrors: true,
8
+ },
9
+ eslint: {
10
+ ignoreDuringBuilds: true,
11
+ },
12
+ generateBuildId: () => packageJson.version,
13
+ };
14
+
15
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lerobot-viewer",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint",
10
+ "format": "prettier --write ."
11
+ },
12
+ "dependencies": {
13
+ "hyparquet": "^1.12.1",
14
+ "next": "15.3.1",
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0",
17
+ "react-icons": "^5.5.0",
18
+ "recharts": "^2.15.3"
19
+ },
20
+ "devDependencies": {
21
+ "@eslint/eslintrc": "^3",
22
+ "@tailwindcss/postcss": "^4",
23
+ "@types/node": "^20",
24
+ "@types/react": "^19",
25
+ "@types/react-dom": "^19",
26
+ "eslint": "^9",
27
+ "eslint-config-next": "15.3.1",
28
+ "prettier": "^3.5.3",
29
+ "tailwindcss": "^4",
30
+ "typescript": "^5"
31
+ }
32
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: ["@tailwindcss/postcss"],
3
+ };
4
+
5
+ export default config;
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { postParentMessageWithParams } from "@/utils/postParentMessage";
6
+ import VideosPlayer from "@/components/videos-player";
7
+ import DataRecharts from "@/components/data-recharts";
8
+ import PlaybackBar from "@/components/playback-bar";
9
+ import { TimeProvider, useTime } from "@/context/time-context";
10
+ import Sidebar from "@/components/side-nav";
11
+ import Loading from "@/components/loading-component";
12
+
13
+ export default function EpisodeViewer({
14
+ data,
15
+ error,
16
+ }: {
17
+ data?: any;
18
+ error?: string;
19
+ }) {
20
+ if (error) {
21
+ return (
22
+ <div className="flex h-screen items-center justify-center bg-slate-950 text-red-400">
23
+ <div className="max-w-xl p-8 rounded bg-slate-900 border border-red-500 shadow-lg">
24
+ <h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
25
+ <p className="text-lg font-mono whitespace-pre-wrap mb-4">{error}</p>
26
+ </div>
27
+ </div>
28
+ );
29
+ }
30
+ return (
31
+ <TimeProvider duration={data.duration}>
32
+ <EpisodeViewerInner data={data} />
33
+ </TimeProvider>
34
+ );
35
+ }
36
+
37
+ function EpisodeViewerInner({ data }: { data: any }) {
38
+ const {
39
+ datasetInfo,
40
+ episodeId,
41
+ videosInfo,
42
+ chartDataGroups,
43
+ episodes,
44
+ ignoredColumns,
45
+ } = data;
46
+
47
+ const [videosReady, setVideosReady] = useState(!videosInfo.length);
48
+ const [chartsReady, setChartsReady] = useState(false);
49
+ const isLoading = !videosReady || !chartsReady;
50
+
51
+ const router = useRouter();
52
+ const searchParams = useSearchParams();
53
+
54
+ // State
55
+ // Use context for time sync
56
+ const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime();
57
+
58
+ // Pagination state
59
+ const pageSize = 100;
60
+ const [currentPage, setCurrentPage] = useState(1);
61
+ const totalPages = Math.ceil(episodes.length / pageSize);
62
+ const paginatedEpisodes = episodes.slice(
63
+ (currentPage - 1) * pageSize,
64
+ currentPage * pageSize,
65
+ );
66
+
67
+ // Initialize based on URL time parameter
68
+ useEffect(() => {
69
+ const timeParam = searchParams.get("t");
70
+ if (timeParam) {
71
+ const timeValue = parseFloat(timeParam);
72
+ if (!isNaN(timeValue)) {
73
+ setCurrentTime(timeValue);
74
+ }
75
+ }
76
+ }, []);
77
+
78
+ // sync with parent window hf.co/spaces
79
+ useEffect(() => {
80
+ postParentMessageWithParams((params: URLSearchParams) => {
81
+ params.set("path", window.location.pathname + window.location.search);
82
+ });
83
+ }, []);
84
+
85
+ // Initialize based on URL time parameter
86
+ useEffect(() => {
87
+ // Initialize page based on current episode
88
+ const episodeIndex = episodes.indexOf(episodeId);
89
+ if (episodeIndex !== -1) {
90
+ setCurrentPage(Math.floor(episodeIndex / pageSize) + 1);
91
+ }
92
+
93
+ // Add keyboard event listener
94
+ window.addEventListener("keydown", handleKeyDown);
95
+ return () => {
96
+ window.removeEventListener("keydown", handleKeyDown);
97
+ };
98
+ }, [episodes, episodeId, pageSize, searchParams]);
99
+
100
+ // Only update URL ?t= param when the integer second changes
101
+ const lastUrlSecondRef = useRef<number>(-1);
102
+ useEffect(() => {
103
+ if (isPlaying) return;
104
+ const currentSec = Math.floor(currentTime);
105
+ if (currentTime > 0 && lastUrlSecondRef.current !== currentSec) {
106
+ lastUrlSecondRef.current = currentSec;
107
+ const newParams = new URLSearchParams(searchParams.toString());
108
+ newParams.set("t", currentSec.toString());
109
+ // Replace state instead of pushing to avoid navigation stack bloat
110
+ window.history.replaceState(
111
+ {},
112
+ "",
113
+ `${window.location.pathname}?${newParams.toString()}`,
114
+ );
115
+ postParentMessageWithParams((params: URLSearchParams) => {
116
+ params.set("path", window.location.pathname + window.location.search);
117
+ });
118
+ }
119
+ }, [isPlaying, currentTime, searchParams]);
120
+
121
+ // Handle keyboard shortcuts
122
+ const handleKeyDown = (e: KeyboardEvent) => {
123
+ const { key } = e;
124
+
125
+ if (key === " ") {
126
+ e.preventDefault();
127
+ setIsPlaying((prev: boolean) => !prev);
128
+ } else if (key === "ArrowDown" || key === "ArrowUp") {
129
+ e.preventDefault();
130
+ const nextEpisodeId = key === "ArrowDown" ? episodeId + 1 : episodeId - 1;
131
+ const lowestEpisodeId = episodes[0];
132
+ const highestEpisodeId = episodes[episodes.length - 1];
133
+
134
+ if (
135
+ nextEpisodeId >= lowestEpisodeId &&
136
+ nextEpisodeId <= highestEpisodeId
137
+ ) {
138
+ router.push(`./episode_${nextEpisodeId}`);
139
+ }
140
+ }
141
+ };
142
+
143
+ // Pagination functions
144
+ const nextPage = () => {
145
+ if (currentPage < totalPages) {
146
+ setCurrentPage((prev) => prev + 1);
147
+ }
148
+ };
149
+
150
+ const prevPage = () => {
151
+ if (currentPage > 1) {
152
+ setCurrentPage((prev) => prev - 1);
153
+ }
154
+ };
155
+
156
+ return (
157
+ <div className="flex h-screen max-h-screen bg-slate-950 text-gray-200">
158
+ {/* Sidebar */}
159
+ <Sidebar
160
+ datasetInfo={datasetInfo}
161
+ paginatedEpisodes={paginatedEpisodes}
162
+ episodeId={episodeId}
163
+ totalPages={totalPages}
164
+ currentPage={currentPage}
165
+ prevPage={prevPage}
166
+ nextPage={nextPage}
167
+ />
168
+
169
+ {/* Content */}
170
+ <div
171
+ className={`flex max-h-screen flex-col gap-4 p-4 md:flex-1 relative ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}
172
+ >
173
+ {isLoading && <Loading />}
174
+
175
+ <div className="flex items-center justify-start my-4">
176
+ <a
177
+ href="https://github.com/huggingface/lerobot"
178
+ target="_blank"
179
+ className="block"
180
+ >
181
+ <img
182
+ src="https://github.com/huggingface/lerobot/raw/main/media/lerobot-logo-thumbnail.png"
183
+ alt="LeRobot Logo"
184
+ className="w-32"
185
+ />
186
+ </a>
187
+
188
+ <div>
189
+ <a
190
+ href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
191
+ target="_blank"
192
+ >
193
+ <p className="text-lg font-semibold">{datasetInfo.repoId}</p>
194
+ </a>
195
+
196
+ <p className="font-mono text-lg font-semibold">
197
+ episode {episodeId}
198
+ </p>
199
+ </div>
200
+ </div>
201
+
202
+ {/* Videos */}
203
+ {videosInfo.length && (
204
+ <VideosPlayer
205
+ videosInfo={videosInfo}
206
+ onVideosReady={() => setVideosReady(true)}
207
+ />
208
+ )}
209
+
210
+ {/* Graph */}
211
+ <div className="mb-4">
212
+ <DataRecharts
213
+ data={chartDataGroups}
214
+ onChartsReady={() => setChartsReady(true)}
215
+ />
216
+
217
+ {ignoredColumns.length > 0 && (
218
+ <p className="mt-2 text-orange-700">
219
+ Columns{" "}
220
+ <span className="font-mono">{ignoredColumns.join(", ")}</span> are
221
+ NOT shown since the visualizer currently does not support 2D or 3D
222
+ data.
223
+ </p>
224
+ )}
225
+ </div>
226
+
227
+ <PlaybackBar />
228
+ </div>
229
+ </div>
230
+ );
231
+ }
src/app/[org]/[dataset]/[episode]/error.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ export default function Error({
6
+ error,
7
+ reset,
8
+ }: {
9
+ error: Error & { digest?: string };
10
+ reset: () => void;
11
+ }) {
12
+ return (
13
+ <div className="flex h-screen items-center justify-center bg-slate-950 text-red-400">
14
+ <div className="max-w-xl p-8 rounded bg-slate-900 border border-red-500 shadow-lg">
15
+ <h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
16
+ <p className="text-lg font-mono whitespace-pre-wrap mb-4">
17
+ {error.message}
18
+ </p>
19
+ <button
20
+ className="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
21
+ onClick={() => reset()}
22
+ >
23
+ Try Again
24
+ </button>
25
+ </div>
26
+ </div>
27
+ );
28
+ }
src/app/[org]/[dataset]/[episode]/fetch-data.ts ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ DatasetMetadata,
3
+ fetchJson,
4
+ fetchParquetFile,
5
+ formatStringWithVars,
6
+ readParquetColumn,
7
+ } from "@/utils/parquetUtils";
8
+ import { pick } from "@/utils/pick";
9
+ import { getDatasetVersion, buildVersionedUrl } from "@/utils/versionUtils";
10
+
11
+ const DATASET_URL =
12
+ process.env.DATASET_URL || "https://huggingface.co/datasets";
13
+
14
+ const SERIES_NAME_DELIMITER = " | ";
15
+
16
+ export async function getEpisodeData(
17
+ org: string,
18
+ dataset: string,
19
+ episodeId: number,
20
+ ) {
21
+ const repoId = `${org}/${dataset}`;
22
+ try {
23
+ const episode_chunk = Math.floor(0 / 1000);
24
+
25
+ // Check for compatible dataset version (v2.1 or v2.0)
26
+ const version = await getDatasetVersion(repoId);
27
+ const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json");
28
+
29
+ const info = await fetchJson<DatasetMetadata>(jsonUrl);
30
+
31
+ // Dataset information
32
+ const datasetInfo = {
33
+ repoId,
34
+ total_frames: info.total_frames,
35
+ total_episodes: info.total_episodes,
36
+ fps: info.fps,
37
+ };
38
+
39
+ // Generate list of episodes
40
+ const episodes =
41
+ process.env.EPISODES === undefined
42
+ ? Array.from(
43
+ { length: datasetInfo.total_episodes },
44
+ // episode id starts from 0
45
+ (_, i) => i,
46
+ )
47
+ : process.env.EPISODES
48
+ .split(/\s+/)
49
+ .map((x) => parseInt(x.trim(), 10))
50
+ .filter((x) => !isNaN(x));
51
+
52
+ // Videos information
53
+ const videosInfo = Object.entries(info.features)
54
+ .filter(([key, value]) => value.dtype === "video")
55
+ .map(([key, _]) => {
56
+ const videoPath = formatStringWithVars(info.video_path, {
57
+ video_key: key,
58
+ episode_chunk: episode_chunk.toString().padStart(3, "0"),
59
+ episode_index: episodeId.toString().padStart(6, "0"),
60
+ });
61
+ return {
62
+ filename: key,
63
+ url: buildVersionedUrl(repoId, version, videoPath),
64
+ };
65
+ });
66
+
67
+ // Column data
68
+ const columnNames = Object.entries(info.features)
69
+ .filter(
70
+ ([key, value]) =>
71
+ ["float32", "int32"].includes(value.dtype) &&
72
+ value.shape.length === 1,
73
+ )
74
+ .map(([key, { shape }]) => ({ key, length: shape[0] }));
75
+
76
+ // Exclude specific columns
77
+ const excludedColumns = [
78
+ "timestamp",
79
+ "frame_index",
80
+ "episode_index",
81
+ "index",
82
+ "task_index",
83
+ ];
84
+ const filteredColumns = columnNames.filter(
85
+ (column) => !excludedColumns.includes(column.key),
86
+ );
87
+ const filteredColumnNames = [
88
+ "timestamp",
89
+ ...filteredColumns.map((column) => column.key),
90
+ ];
91
+
92
+ const columns = filteredColumns.map(({ key }) => {
93
+ let column_names = info.features[key].names;
94
+ while (typeof column_names === "object") {
95
+ if (Array.isArray(column_names)) break;
96
+ column_names = Object.values(column_names ?? {})[0];
97
+ }
98
+ return {
99
+ key,
100
+ value: Array.isArray(column_names)
101
+ ? column_names.map((name) => `${key}${SERIES_NAME_DELIMITER}${name}`)
102
+ : Array.from(
103
+ { length: columnNames.find((c) => c.key === key)?.length ?? 1 },
104
+ (_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
105
+ ),
106
+ };
107
+ });
108
+
109
+ const parquetUrl = buildVersionedUrl(
110
+ repoId,
111
+ version,
112
+ formatStringWithVars(info.data_path, {
113
+ episode_chunk: episode_chunk.toString().padStart(3, "0"),
114
+ episode_index: episodeId.toString().padStart(6, "0"),
115
+ })
116
+ );
117
+
118
+ const arrayBuffer = await fetchParquetFile(parquetUrl);
119
+ const data = await readParquetColumn(arrayBuffer, filteredColumnNames);
120
+ // Flatten and map to array of objects for chartData
121
+ const seriesNames = [
122
+ "timestamp",
123
+ ...columns.map(({ value }) => value).flat(),
124
+ ];
125
+
126
+ const chartData = data.map((row) => {
127
+ const flatRow = row.flat();
128
+ const obj: Record<string, number> = {};
129
+ seriesNames.forEach((key, idx) => {
130
+ obj[key] = flatRow[idx];
131
+ });
132
+ return obj;
133
+ });
134
+
135
+ // List of columns that are ignored (e.g., 2D or 3D data)
136
+ const ignoredColumns = Object.entries(info.features)
137
+ .filter(
138
+ ([key, value]) =>
139
+ ["float32", "int32"].includes(value.dtype) && value.shape.length > 1,
140
+ )
141
+ .map(([key]) => key);
142
+
143
+ // 1. Group all numeric keys by suffix (excluding 'timestamp')
144
+ const numericKeys = seriesNames.filter((k) => k !== "timestamp");
145
+ const suffixGroupsMap: Record<string, string[]> = {};
146
+ for (const key of numericKeys) {
147
+ const parts = key.split(SERIES_NAME_DELIMITER);
148
+ const suffix = parts[1] || parts[0]; // fallback to key if no delimiter
149
+ if (!suffixGroupsMap[suffix]) suffixGroupsMap[suffix] = [];
150
+ suffixGroupsMap[suffix].push(key);
151
+ }
152
+ const suffixGroups = Object.values(suffixGroupsMap);
153
+
154
+ // 2. Compute min/max for each suffix group as a whole
155
+ const groupStats: Record<string, { min: number; max: number }> = {};
156
+ suffixGroups.forEach((group) => {
157
+ let min = Infinity,
158
+ max = -Infinity;
159
+ for (const row of chartData) {
160
+ for (const key of group) {
161
+ const v = row[key];
162
+ if (typeof v === "number" && !isNaN(v)) {
163
+ if (v < min) min = v;
164
+ if (v > max) max = v;
165
+ }
166
+ }
167
+ }
168
+ // Use the first key in the group as the group id
169
+ groupStats[group[0]] = { min, max };
170
+ });
171
+
172
+ // 3. Group suffix groups by similar scale (treat each suffix group as a unit)
173
+ const scaleGroups: Record<string, string[][]> = {};
174
+ const used = new Set<string>();
175
+ const SCALE_THRESHOLD = 2;
176
+ for (const group of suffixGroups) {
177
+ const groupId = group[0];
178
+ if (used.has(groupId)) continue;
179
+ const { min, max } = groupStats[groupId];
180
+ if (!isFinite(min) || !isFinite(max)) continue;
181
+ const logMin = Math.log10(Math.abs(min) + 1e-9);
182
+ const logMax = Math.log10(Math.abs(max) + 1e-9);
183
+ const unit: string[][] = [group];
184
+ used.add(groupId);
185
+ for (const other of suffixGroups) {
186
+ const otherId = other[0];
187
+ if (used.has(otherId) || otherId === groupId) continue;
188
+ const { min: omin, max: omax } = groupStats[otherId];
189
+ if (!isFinite(omin) || !isFinite(omax) || omin === omax) continue;
190
+ const ologMin = Math.log10(Math.abs(omin) + 1e-9);
191
+ const ologMax = Math.log10(Math.abs(omax) + 1e-9);
192
+ if (
193
+ Math.abs(logMin - ologMin) <= SCALE_THRESHOLD &&
194
+ Math.abs(logMax - ologMax) <= SCALE_THRESHOLD
195
+ ) {
196
+ unit.push(other);
197
+ used.add(otherId);
198
+ }
199
+ }
200
+ scaleGroups[groupId] = unit;
201
+ }
202
+
203
+ // 4. Flatten scaleGroups into chartGroups (array of arrays of keys)
204
+ const chartGroups: string[][] = Object.values(scaleGroups)
205
+ .sort((a, b) => b.length - a.length)
206
+ .flatMap((suffixGroupArr) => {
207
+ // suffixGroupArr is array of suffix groups (each is array of keys)
208
+ const merged = suffixGroupArr.flat();
209
+ if (merged.length > 6) {
210
+ const subgroups = [];
211
+ for (let i = 0; i < merged.length; i += 6) {
212
+ subgroups.push(merged.slice(i, i + 6));
213
+ }
214
+ return subgroups;
215
+ }
216
+ return [merged];
217
+ });
218
+
219
+ const duration = chartData[chartData.length - 1].timestamp;
220
+
221
+ // Utility: group row keys by suffix
222
+ function groupRowBySuffix(row: Record<string, number>): Record<string, any> {
223
+ const result: Record<string, any> = {};
224
+ const suffixGroups: Record<string, Record<string, number>> = {};
225
+ for (const [key, value] of Object.entries(row)) {
226
+ if (key === "timestamp") {
227
+ result["timestamp"] = value;
228
+ continue;
229
+ }
230
+ const parts = key.split(SERIES_NAME_DELIMITER);
231
+ if (parts.length === 2) {
232
+ const [prefix, suffix] = parts;
233
+ if (!suffixGroups[suffix]) suffixGroups[suffix] = {};
234
+ suffixGroups[suffix][prefix] = value;
235
+ } else {
236
+ result[key] = value;
237
+ }
238
+ }
239
+ for (const [suffix, group] of Object.entries(suffixGroups)) {
240
+ const keys = Object.keys(group);
241
+ if (keys.length === 1) {
242
+ // Use the full original name as the key
243
+ const fullName = `${keys[0]}${SERIES_NAME_DELIMITER}${suffix}`;
244
+ result[fullName] = group[keys[0]];
245
+ } else {
246
+ result[suffix] = group;
247
+ }
248
+ }
249
+ return result;
250
+ }
251
+
252
+ const chartDataGroups = chartGroups.map((group) =>
253
+ chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
254
+ );
255
+
256
+ return {
257
+ datasetInfo,
258
+ episodeId,
259
+ videosInfo,
260
+ chartDataGroups,
261
+ episodes,
262
+ ignoredColumns,
263
+ duration,
264
+ };
265
+ } catch (err) {
266
+ console.error("Error loading episode data:", err);
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ // Safe wrapper for UI error display
272
+ export async function getEpisodeDataSafe(
273
+ org: string,
274
+ dataset: string,
275
+ episodeId: number,
276
+ ): Promise<{ data?: any; error?: string }> {
277
+ try {
278
+ const data = await getEpisodeData(org, dataset, episodeId);
279
+ return { data };
280
+ } catch (err: any) {
281
+ // Only expose the error message, not stack or sensitive info
282
+ return { error: err?.message || String(err) || "Unknown error" };
283
+ }
284
+ }
src/app/[org]/[dataset]/[episode]/page.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import EpisodeViewer from "./episode-viewer";
2
+ import { getEpisodeDataSafe } from "./fetch-data";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function generateMetadata({
7
+ params,
8
+ }: {
9
+ params: Promise<{ org: string; dataset: string; episode: string }>;
10
+ }) {
11
+ const { org, dataset, episode } = await params;
12
+ return {
13
+ title: `${org}/${dataset} | episode ${episode}`,
14
+ };
15
+ }
16
+
17
+ export default async function EpisodePage({
18
+ params,
19
+ }: {
20
+ params: Promise<{ org: string; dataset: string; episode: string }>;
21
+ }) {
22
+ // episode is like 'episode_1'
23
+ const { org, dataset, episode } = await params;
24
+ // fetchData should be updated if needed to support this path pattern
25
+ const episodeNumber = Number(episode.replace(/^episode_/, ""));
26
+ const { data, error } = await getEpisodeDataSafe(org, dataset, episodeNumber);
27
+ return <EpisodeViewer data={data} error={error} />;
28
+ }
src/app/[org]/[dataset]/page.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "next/navigation";
2
+
3
+ export default async function DatasetRootPage({
4
+ params,
5
+ }: {
6
+ params: Promise<{ org: string; dataset: string }>;
7
+ }) {
8
+ const { org, dataset } = await params;
9
+ const episodeN = process.env.EPISODES
10
+ ?.split(/\s+/)
11
+ .map((x) => parseInt(x.trim(), 10))
12
+ .filter((x) => !isNaN(x))[0] ?? 0;
13
+
14
+ redirect(`/${org}/${dataset}/episode_${episodeN}`);
15
+ }
src/app/explore/explore-grid.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef } from "react";
4
+ import Link from "next/link";
5
+
6
+ import { useRouter, useSearchParams } from "next/navigation";
7
+ import { postParentMessageWithParams } from "@/utils/postParentMessage";
8
+
9
+ type ExploreGridProps = {
10
+ datasets: Array<{ id: string; videoUrl: string | null }>;
11
+ currentPage: number;
12
+ totalPages: number;
13
+ };
14
+
15
+ export default function ExploreGrid({
16
+ datasets,
17
+ currentPage,
18
+ totalPages,
19
+ }: ExploreGridProps) {
20
+ // sync with parent window hf.co/spaces
21
+ useEffect(() => {
22
+ postParentMessageWithParams((params: URLSearchParams) => {
23
+ params.set("path", window.location.pathname + window.location.search);
24
+ });
25
+ }, []);
26
+
27
+ // Create an array of refs for each video
28
+ const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
29
+
30
+ return (
31
+ <main className="p-8">
32
+ <h1 className="text-2xl font-bold mb-6">Explore LeRobot Datasets</h1>
33
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
34
+ {datasets.map((ds, idx) => (
35
+ <Link
36
+ key={ds.id}
37
+ href={`/${ds.id}`}
38
+ className="relative border rounded-lg p-4 bg-white shadow hover:shadow-lg transition overflow-hidden h-48 flex items-end group"
39
+ onMouseEnter={() => {
40
+ const vid = videoRefs.current[idx];
41
+ if (vid) vid.play();
42
+ }}
43
+ onMouseLeave={() => {
44
+ const vid = videoRefs.current[idx];
45
+ if (vid) {
46
+ vid.pause();
47
+ vid.currentTime = 0;
48
+ }
49
+ }}
50
+ >
51
+ <video
52
+ ref={(el) => {
53
+ videoRefs.current[idx] = el;
54
+ }}
55
+ src={ds.videoUrl || undefined}
56
+ className="absolute top-0 left-0 w-full h-full object-cover object-center z-0"
57
+ loop
58
+ muted
59
+ playsInline
60
+ preload="metadata"
61
+ onTimeUpdate={(e) => {
62
+ const vid = e.currentTarget;
63
+ if (vid.currentTime >= 15) {
64
+ vid.pause();
65
+ vid.currentTime = 0;
66
+ }
67
+ }}
68
+ />
69
+ <div className="absolute top-0 left-0 w-full h-full bg-black/40 z-10 pointer-events-none" />
70
+ <div className="relative z-20 font-mono text-blue-100 break-all text-sm bg-black/60 backdrop-blur px-2 py-1 rounded shadow">
71
+ {ds.id}
72
+ </div>
73
+ </Link>
74
+ ))}
75
+ </div>
76
+ <div className="flex justify-center mt-8 gap-4">
77
+ {currentPage > 1 && (
78
+ <button
79
+ className="px-6 py-2 bg-gray-600 text-white rounded shadow hover:bg-gray-700 transition"
80
+ onClick={() => {
81
+ const params = new URLSearchParams(window.location.search);
82
+ params.set("p", (currentPage - 1).toString());
83
+ window.location.search = params.toString();
84
+ }}
85
+ >
86
+ Previous
87
+ </button>
88
+ )}
89
+ {currentPage < totalPages && (
90
+ <button
91
+ className="px-6 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700 transition"
92
+ onClick={() => {
93
+ const params = new URLSearchParams(window.location.search);
94
+ params.set("p", (currentPage + 1).toString());
95
+ window.location.search = params.toString();
96
+ }}
97
+ >
98
+ Next
99
+ </button>
100
+ )}
101
+ </div>
102
+ </main>
103
+ );
104
+ }
src/app/explore/page.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ExploreGrid from "./explore-grid";
3
+ import {
4
+ DatasetMetadata,
5
+ fetchJson,
6
+ formatStringWithVars,
7
+ } from "@/utils/parquetUtils";
8
+ import { getDatasetVersion, buildVersionedUrl } from "@/utils/versionUtils";
9
+
10
+ export default async function ExplorePage({
11
+ searchParams,
12
+ }: {
13
+ searchParams: { p?: string };
14
+ }) {
15
+ let datasets: any[] = [];
16
+ let currentPage = 1;
17
+ let totalPages = 1;
18
+ try {
19
+ const res = await fetch(
20
+ "https://huggingface.co/api/datasets?sort=lastModified&filter=LeRobot",
21
+ {
22
+ cache: "no-store",
23
+ },
24
+ );
25
+ if (!res.ok) throw new Error("Failed to fetch datasets");
26
+ const data = await res.json();
27
+ const allDatasets = data.datasets || data;
28
+ // Use searchParams from props
29
+ const page = parseInt(searchParams?.p || "1", 10);
30
+ const perPage = 30;
31
+
32
+ currentPage = page;
33
+ totalPages = Math.ceil(allDatasets.length / perPage);
34
+
35
+ const startIdx = (currentPage - 1) * perPage;
36
+ const endIdx = startIdx + perPage;
37
+ datasets = allDatasets.slice(startIdx, endIdx);
38
+ } catch (e) {
39
+ return <div className="p-8 text-red-600">Failed to load datasets.</div>;
40
+ }
41
+
42
+ // Fetch episode 0 data for each dataset
43
+ const datasetWithVideos = (
44
+ await Promise.all(
45
+ datasets.map(async (ds: any) => {
46
+ try {
47
+ const [org, dataset] = ds.id.split("/");
48
+ const repoId = `${org}/${dataset}`;
49
+
50
+ // Try to get compatible version, but don't fail the entire page if incompatible
51
+ let version: string;
52
+ try {
53
+ version = await getDatasetVersion(repoId);
54
+ } catch (err) {
55
+ // Dataset is not compatible, skip it silently
56
+ console.warn(`Skipping incompatible dataset ${repoId}: ${err instanceof Error ? err.message : err}`);
57
+ return null;
58
+ }
59
+
60
+ const jsonUrl = buildVersionedUrl(repoId, version, "meta/info.json");
61
+ const info = await fetchJson<DatasetMetadata>(jsonUrl);
62
+ const videoEntry = Object.entries(info.features).find(
63
+ ([key, value]) => value.dtype === "video",
64
+ );
65
+ let videoUrl: string | null = null;
66
+ if (videoEntry) {
67
+ const [key] = videoEntry;
68
+ const videoPath = formatStringWithVars(info.video_path, {
69
+ video_key: key,
70
+ episode_chunk: "0".padStart(3, "0"),
71
+ episode_index: "0".padStart(6, "0"),
72
+ });
73
+ const url = buildVersionedUrl(repoId, version, videoPath);
74
+ // Check if videoUrl exists (status 200)
75
+ try {
76
+ const headRes = await fetch(url, { method: "HEAD" });
77
+ if (headRes.ok) {
78
+ videoUrl = url;
79
+ }
80
+ } catch (e) {
81
+ // If fetch fails, videoUrl remains null
82
+ }
83
+ }
84
+ return videoUrl ? { id: repoId, videoUrl } : null;
85
+ } catch (err) {
86
+ console.error(
87
+ `Failed to fetch or parse dataset info for ${ds.id}:`,
88
+ err,
89
+ );
90
+ return null;
91
+ }
92
+ }),
93
+ )
94
+ ).filter(Boolean) as { id: string; videoUrl: string | null }[];
95
+
96
+ return (
97
+ <ExploreGrid
98
+ datasets={datasetWithVideos}
99
+ currentPage={currentPage}
100
+ totalPages={totalPages}
101
+ />
102
+ );
103
+ }
src/app/favicon.ico ADDED
src/app/globals.css ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --background: #ffffff;
5
+ --foreground: #171717;
6
+ }
7
+
8
+ @theme inline {
9
+ --color-background: var(--background);
10
+ --color-foreground: var(--foreground);
11
+ --font-sans: var(--font-geist-sans);
12
+ --font-mono: var(--font-geist-mono);
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --background: #0a0a0a;
18
+ --foreground: #ededed;
19
+ }
20
+ }
21
+
22
+ body {
23
+ background: var(--background);
24
+ color: var(--foreground);
25
+ font-family: Arial, Helvetica, sans-serif;
26
+ }
27
+
28
+ .video-background {
29
+ @apply fixed top-0 right-0 bottom-0 left-0 -z-10 overflow-hidden w-screen h-screen;
30
+ }
31
+ .video-background iframe {
32
+ @apply absolute top-1/2 left-1/2 border-0 pointer-events-none bg-black;
33
+ width: 100vw;
34
+ height: 100vh;
35
+ transform: translate(-50%, -50%);
36
+ }
37
+ @media (min-aspect-ratio: 16/9) {
38
+ .video-background iframe {
39
+ height: 56.25vw;
40
+ }
41
+ }
42
+ @media (max-aspect-ratio: 16/9) {
43
+ .video-background iframe {
44
+ width: 177.78vh;
45
+ }
46
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({ subsets: ["latin"] });
6
+
7
+ export const metadata: Metadata = {
8
+ title: "LeRobot Dataset Visualizer",
9
+ description: "Visualization of LeRobot Datasets",
10
+ };
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: {
15
+ children: React.ReactNode;
16
+ }) {
17
+ return (
18
+ <html lang="en">
19
+ <body className={inter.className}>{children}</body>
20
+ </html>
21
+ );
22
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useSearchParams } from "next/navigation";
6
+
7
+ export default function Home() {
8
+ const searchParams = useSearchParams();
9
+ const router = useRouter();
10
+
11
+ // Handle redirects with useEffect instead of direct redirect
12
+ useEffect(() => {
13
+ // Redirect to the first episode of the dataset if REPO_ID is defined
14
+ if (process.env.REPO_ID) {
15
+ const episodeN = process.env.EPISODES
16
+ ?.split(/\s+/)
17
+ .map((x) => parseInt(x.trim(), 10))
18
+ .filter((x) => !isNaN(x))[0] ?? 0;
19
+
20
+ router.push(`/${process.env.REPO_ID}/episode_${episodeN}`);
21
+ return;
22
+ }
23
+
24
+ // sync with hf.co/spaces URL params
25
+ if (searchParams.get('path')) {
26
+ router.push(searchParams.get('path')!);
27
+ return;
28
+ }
29
+
30
+ // legacy sync with hf.co/spaces URL params
31
+ let redirectUrl: string | null = null;
32
+ if (searchParams.get('dataset') && searchParams.get('episode')) {
33
+ redirectUrl = `/${searchParams.get('dataset')}/episode_${searchParams.get('episode')}`;
34
+ } else if (searchParams.get('dataset')) {
35
+ redirectUrl = `/${searchParams.get('dataset')}`;
36
+ }
37
+
38
+ if (redirectUrl && searchParams.get('t')) {
39
+ redirectUrl += `?t=${searchParams.get('t')}`;
40
+ }
41
+
42
+ if (redirectUrl) {
43
+ router.push(redirectUrl);
44
+ return;
45
+ }
46
+ }, [searchParams, router]);
47
+
48
+ const playerRef = useRef<any>(null);
49
+
50
+ useEffect(() => {
51
+ // Load YouTube IFrame API if not already present
52
+ if (!(window as any).YT) {
53
+ const tag = document.createElement("script");
54
+ tag.src = "https://www.youtube.com/iframe_api";
55
+ document.body.appendChild(tag);
56
+ }
57
+ let interval: NodeJS.Timeout;
58
+ (window as any).onYouTubeIframeAPIReady = () => {
59
+ playerRef.current = new (window as any).YT.Player("yt-bg-player", {
60
+ videoId: "Er8SPJsIYr0",
61
+ playerVars: {
62
+ autoplay: 1,
63
+ mute: 1,
64
+ controls: 0,
65
+ showinfo: 0,
66
+ modestbranding: 1,
67
+ rel: 0,
68
+ loop: 1,
69
+ fs: 0,
70
+ playlist: "Er8SPJsIYr0",
71
+ start: 0,
72
+ },
73
+ events: {
74
+ onReady: (event: any) => {
75
+ event.target.playVideo();
76
+ event.target.mute();
77
+ interval = setInterval(() => {
78
+ const t = event.target.getCurrentTime();
79
+ if (t >= 60) {
80
+ event.target.seekTo(0);
81
+ }
82
+ }, 500);
83
+ },
84
+ },
85
+ });
86
+ };
87
+ return () => {
88
+ if (interval) clearInterval(interval);
89
+ if (playerRef.current && playerRef.current.destroy)
90
+ playerRef.current.destroy();
91
+ };
92
+ }, []);
93
+
94
+ const inputRef = useRef<HTMLInputElement>(null);
95
+
96
+ const handleGo = (e: React.FormEvent) => {
97
+ e.preventDefault();
98
+ const value = inputRef.current?.value.trim();
99
+ if (value) {
100
+ router.push(value);
101
+ }
102
+ };
103
+
104
+ return (
105
+ <div className="relative h-screen w-screen overflow-hidden">
106
+ {/* YouTube Video Background */}
107
+ <div className="video-background">
108
+ <div id="yt-bg-player" />
109
+ </div>
110
+ {/* Overlay */}
111
+ <div className="fixed top-0 right-0 bottom-0 left-0 bg-black/60 -z-0" />
112
+ {/* Centered Content */}
113
+ <div className="relative z-10 h-screen flex flex-col items-center justify-center text-white text-center">
114
+ <h1 className="text-4xl md:text-5xl font-bold mb-6 drop-shadow-lg">
115
+ LeRobot Dataset Visualizer
116
+ </h1>
117
+ <a
118
+ href="https://x.com/RemiCadene/status/1825455895561859185"
119
+ target="_blank"
120
+ rel="noopener noreferrer"
121
+ className="text-sky-400 font-medium text-lg underline mb-8 inline-block hover:text-sky-300 transition-colors"
122
+ >
123
+ create & train your own robots
124
+ </a>
125
+ <form onSubmit={handleGo} className="flex gap-2 justify-center mt-6">
126
+ <input
127
+ ref={inputRef}
128
+ type="text"
129
+ placeholder="Enter dataset id (e.g. lerobot/visualize_dataset)"
130
+ className="px-4 py-2 rounded-md text-base text-white border-white border-1 focus:outline-none min-w-[220px] shadow-md"
131
+ onKeyDown={(e) => {
132
+ if (e.key === "Enter") {
133
+ // Prevent double submission if form onSubmit also fires
134
+ e.preventDefault();
135
+ handleGo(e as any);
136
+ }
137
+ }}
138
+ />
139
+ <button
140
+ type="submit"
141
+ className="px-5 py-2 rounded-md bg-sky-400 text-black font-semibold text-base hover:bg-sky-300 transition-colors shadow-md"
142
+ >
143
+ Go
144
+ </button>
145
+ </form>
146
+ {/* Example Datasets */}
147
+ <div className="mt-8">
148
+ <div className="font-semibold mb-2 text-lg">Example Datasets:</div>
149
+ <div className="flex flex-col gap-2 items-center">
150
+ {[
151
+ "lerobot/aloha_static_cups_open",
152
+ "lerobot/columbia_cairlab_pusht_real",
153
+ "lerobot/taco_play",
154
+ ].map((ds) => (
155
+ <button
156
+ key={ds}
157
+ type="button"
158
+ className="px-4 py-2 rounded bg-slate-700 text-sky-200 hover:bg-sky-700 hover:text-white transition-colors shadow"
159
+ onClick={() => {
160
+ if (inputRef.current) {
161
+ inputRef.current.value = ds;
162
+ inputRef.current.focus();
163
+ }
164
+ router.push(ds);
165
+ }}
166
+ >
167
+ {ds}
168
+ </button>
169
+ ))}
170
+ </div>
171
+ </div>
172
+
173
+ <Link
174
+ href="/explore"
175
+ className="inline-block px-6 py-3 mt-8 rounded-md bg-sky-500 text-white font-semibold text-lg shadow-lg hover:bg-sky-400 transition-colors"
176
+ >
177
+ Explore Open Datasets
178
+ </Link>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
src/components/data-recharts.tsx ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useTime } from "../context/time-context";
5
+ import {
6
+ LineChart,
7
+ Line,
8
+ XAxis,
9
+ YAxis,
10
+ CartesianGrid,
11
+ ResponsiveContainer,
12
+ Tooltip,
13
+ } from "recharts";
14
+
15
+ type DataGraphProps = {
16
+ data: Array<Array<Record<string, number>>>;
17
+ onChartsReady?: () => void;
18
+ };
19
+
20
+ import React, { useMemo } from "react";
21
+
22
+ export const DataRecharts = React.memo(
23
+ ({ data, onChartsReady }: DataGraphProps) => {
24
+ // Shared hoveredTime for all graphs
25
+ const [hoveredTime, setHoveredTime] = useState<number | null>(null);
26
+
27
+ if (!Array.isArray(data) || data.length === 0) return null;
28
+
29
+ useEffect(() => {
30
+ if (typeof onChartsReady === "function") {
31
+ onChartsReady();
32
+ }
33
+ }, [onChartsReady]);
34
+
35
+ return (
36
+ <div className="grid md:grid-cols-2 grid-cols-1 gap-4">
37
+ {data.map((group, idx) => (
38
+ <SingleDataGraph
39
+ key={idx}
40
+ data={group}
41
+ hoveredTime={hoveredTime}
42
+ setHoveredTime={setHoveredTime}
43
+ />
44
+ ))}
45
+ </div>
46
+ );
47
+ },
48
+ );
49
+
50
+ const NESTED_KEY_DELIMITER = ",";
51
+
52
+ const SingleDataGraph = React.memo(
53
+ ({
54
+ data,
55
+ hoveredTime,
56
+ setHoveredTime,
57
+ }: {
58
+ data: Array<Record<string, number>>;
59
+ hoveredTime: number | null;
60
+ setHoveredTime: (t: number | null) => void;
61
+ }) => {
62
+ const { currentTime, setCurrentTime } = useTime();
63
+ function flattenRow(row: Record<string, any>, prefix = ""): Record<string, number> {
64
+ const result: Record<string, number> = {};
65
+ for (const [key, value] of Object.entries(row)) {
66
+ // Special case: if this is a group value that is a primitive, assign to prefix.key
67
+ if (typeof value === "number") {
68
+ if (prefix) {
69
+ result[`${prefix}${NESTED_KEY_DELIMITER}${key}`] = value;
70
+ } else {
71
+ result[key] = value;
72
+ }
73
+ } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
74
+ // If it's an object, recurse
75
+ Object.assign(result, flattenRow(value, prefix ? `${prefix}${NESTED_KEY_DELIMITER}${key}` : key));
76
+ }
77
+ }
78
+ // Always keep timestamp at top level if present
79
+ if ("timestamp" in row) {
80
+ result["timestamp"] = row["timestamp"];
81
+ }
82
+ return result;
83
+ }
84
+
85
+ // Flatten all rows for recharts
86
+ const chartData = useMemo(() => data.map(row => flattenRow(row)), [data]);
87
+ const [dataKeys, setDataKeys] = useState<string[]>([]);
88
+ const [visibleKeys, setVisibleKeys] = useState<string[]>([]);
89
+
90
+ useEffect(() => {
91
+ if (!chartData || chartData.length === 0) return;
92
+ // Get all keys except timestamp from the first row
93
+ const keys = Object.keys(chartData[0]).filter((k) => k !== "timestamp");
94
+ setDataKeys(keys);
95
+ setVisibleKeys(keys);
96
+ }, [chartData]);
97
+
98
+ // Parse dataKeys into groups (dot notation)
99
+ const groups: Record<string, string[]> = {};
100
+ const singles: string[] = [];
101
+ dataKeys.forEach((key) => {
102
+ const parts = key.split(NESTED_KEY_DELIMITER);
103
+ if (parts.length > 1) {
104
+ const group = parts[0];
105
+ if (!groups[group]) groups[group] = [];
106
+ groups[group].push(key);
107
+ } else {
108
+ singles.push(key);
109
+ }
110
+ });
111
+
112
+ // Assign a color per group (and for singles)
113
+ const allGroups = [...Object.keys(groups), ...singles];
114
+ const groupColorMap: Record<string, string> = {};
115
+ allGroups.forEach((group, idx) => {
116
+ groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`;
117
+ });
118
+
119
+ // Find the closest data point to the current time for highlighting
120
+ const findClosestDataIndex = (time: number) => {
121
+ if (!chartData.length) return 0;
122
+ // Find the index of the first data point whose timestamp is >= time (ceiling)
123
+ const idx = chartData.findIndex((point) => point.timestamp >= time);
124
+ if (idx !== -1) return idx;
125
+ // If all timestamps are less than time, return the last index
126
+ return chartData.length - 1;
127
+ };
128
+
129
+ const handleMouseLeave = () => {
130
+ setHoveredTime(null);
131
+ };
132
+
133
+ const handleClick = (data: any) => {
134
+ if (data && data.activePayload && data.activePayload.length) {
135
+ const timeValue = data.activePayload[0].payload.timestamp;
136
+ setCurrentTime(timeValue);
137
+ }
138
+ };
139
+
140
+ // Custom legend to show current value next to each series
141
+ const CustomLegend = () => {
142
+ const closestIndex = findClosestDataIndex(
143
+ hoveredTime != null ? hoveredTime : currentTime,
144
+ );
145
+ const currentData = chartData[closestIndex] || {};
146
+
147
+ // Parse dataKeys into groups (dot notation)
148
+ const groups: Record<string, string[]> = {};
149
+ const singles: string[] = [];
150
+ dataKeys.forEach((key) => {
151
+ const parts = key.split(NESTED_KEY_DELIMITER);
152
+ if (parts.length > 1) {
153
+ const group = parts[0];
154
+ if (!groups[group]) groups[group] = [];
155
+ groups[group].push(key);
156
+ } else {
157
+ singles.push(key);
158
+ }
159
+ });
160
+
161
+ // Assign a color per group (and for singles)
162
+ const allGroups = [...Object.keys(groups), ...singles];
163
+ const groupColorMap: Record<string, string> = {};
164
+ allGroups.forEach((group, idx) => {
165
+ groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`;
166
+ });
167
+
168
+ const isGroupChecked = (group: string) => groups[group].every(k => visibleKeys.includes(k));
169
+ const isGroupIndeterminate = (group: string) => groups[group].some(k => visibleKeys.includes(k)) && !isGroupChecked(group);
170
+
171
+ const handleGroupCheckboxChange = (group: string) => {
172
+ if (isGroupChecked(group)) {
173
+ // Uncheck all children
174
+ setVisibleKeys((prev) => prev.filter(k => !groups[group].includes(k)));
175
+ } else {
176
+ // Check all children
177
+ setVisibleKeys((prev) => Array.from(new Set([...prev, ...groups[group]])));
178
+ }
179
+ };
180
+
181
+ const handleCheckboxChange = (key: string) => {
182
+ setVisibleKeys((prev) =>
183
+ prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
184
+ );
185
+ };
186
+
187
+ return (
188
+ <div className="grid grid-cols-[repeat(auto-fit,250px)] gap-4 mx-8">
189
+ {/* Grouped keys */}
190
+ {Object.entries(groups).map(([group, children]) => {
191
+ const color = groupColorMap[group];
192
+ return (
193
+ <div key={group} className="mb-2">
194
+ <label className="flex gap-2 cursor-pointer select-none font-semibold">
195
+ <input
196
+ type="checkbox"
197
+ checked={isGroupChecked(group)}
198
+ ref={el => { if (el) el.indeterminate = isGroupIndeterminate(group); }}
199
+ onChange={() => handleGroupCheckboxChange(group)}
200
+ className="size-3.5 mt-1"
201
+ style={{ accentColor: color }}
202
+ />
203
+ <span className="text-sm w-40 text-white">{group}</span>
204
+ </label>
205
+ <div className="pl-7 flex flex-col gap-1 mt-1">
206
+ {children.map((key) => (
207
+ <label key={key} className="flex gap-2 cursor-pointer select-none">
208
+ <input
209
+ type="checkbox"
210
+ checked={visibleKeys.includes(key)}
211
+ onChange={() => handleCheckboxChange(key)}
212
+ className="size-3.5 mt-1"
213
+ style={{ accentColor: color }}
214
+ />
215
+ <span className={`text-xs break-all w-36 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key.slice(group.length + 1)}</span>
216
+ <span className={`text-xs font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}>
217
+ {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"}
218
+ </span>
219
+ </label>
220
+ ))}
221
+ </div>
222
+ </div>
223
+ );
224
+ })}
225
+ {/* Singles (non-grouped) */}
226
+ {singles.map((key) => {
227
+ const color = groupColorMap[key];
228
+ return (
229
+ <label key={key} className="flex gap-2 cursor-pointer select-none">
230
+ <input
231
+ type="checkbox"
232
+ checked={visibleKeys.includes(key)}
233
+ onChange={() => handleCheckboxChange(key)}
234
+ className="size-3.5 mt-1"
235
+ style={{ accentColor: color }}
236
+ />
237
+ <span className={`text-sm break-all w-40 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key}</span>
238
+ <span className={`text-sm font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}>
239
+ {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"}
240
+ </span>
241
+ </label>
242
+ );
243
+ })}
244
+ </div>
245
+ );
246
+ };
247
+
248
+ return (
249
+ <div className="w-full">
250
+ <div className="w-full h-80" onMouseLeave={handleMouseLeave}>
251
+ <ResponsiveContainer width="100%" height="100%">
252
+ <LineChart
253
+ data={chartData}
254
+ syncId="episode-sync"
255
+ margin={{ top: 24, right: 16, left: 0, bottom: 16 }}
256
+ onClick={handleClick}
257
+ onMouseMove={(state: any) => {
258
+ setHoveredTime(
259
+ state?.activePayload?.[0]?.payload?.timestamp ??
260
+ state?.activeLabel ??
261
+ null,
262
+ );
263
+ }}
264
+ onMouseLeave={handleMouseLeave}
265
+ >
266
+ <CartesianGrid strokeDasharray="3 3" stroke="#444" />
267
+ <XAxis
268
+ dataKey="timestamp"
269
+ label={{
270
+ value: "time",
271
+ position: "insideBottomLeft",
272
+ fill: "#cbd5e1",
273
+ }}
274
+ domain={[
275
+ chartData.at(0)?.timestamp ?? 0,
276
+ chartData.at(-1)?.timestamp ?? 0,
277
+ ]}
278
+ ticks={useMemo(
279
+ () =>
280
+ Array.from(
281
+ new Set(chartData.map((d) => Math.ceil(d.timestamp))),
282
+ ),
283
+ [chartData],
284
+ )}
285
+ stroke="#cbd5e1"
286
+ minTickGap={20} // Increased for fewer ticks
287
+ allowDataOverflow={true}
288
+ />
289
+ <YAxis
290
+ domain={["auto", "auto"]}
291
+ stroke="#cbd5e1"
292
+ interval={0}
293
+ allowDataOverflow={true}
294
+ />
295
+
296
+ <Tooltip
297
+ content={() => null}
298
+ active={true}
299
+ isAnimationActive={false}
300
+ defaultIndex={
301
+ !hoveredTime ? findClosestDataIndex(currentTime) : undefined
302
+ }
303
+ />
304
+
305
+ {/* Render lines for visible dataKeys only */}
306
+ {dataKeys.map((key) => {
307
+ // Use group color for all keys in a group
308
+ const group = key.includes(NESTED_KEY_DELIMITER) ? key.split(NESTED_KEY_DELIMITER)[0] : key;
309
+ const color = groupColorMap[group];
310
+ let strokeDasharray: string | undefined = undefined;
311
+ if (groups[group] && groups[group].length > 1) {
312
+ const idxInGroup = groups[group].indexOf(key);
313
+ if (idxInGroup > 0) strokeDasharray = "5 5";
314
+ }
315
+ return (
316
+ visibleKeys.includes(key) && (
317
+ <Line
318
+ key={key}
319
+ type="monotone"
320
+ dataKey={key}
321
+ name={key}
322
+ stroke={color}
323
+ strokeDasharray={strokeDasharray}
324
+ dot={false}
325
+ activeDot={false}
326
+ strokeWidth={1.5}
327
+ isAnimationActive={false}
328
+ />
329
+ )
330
+ );
331
+ })}
332
+ </LineChart>
333
+ </ResponsiveContainer>
334
+ </div>
335
+ <CustomLegend />
336
+ </div>
337
+ );
338
+ },
339
+ ); // End React.memo
340
+
341
+ SingleDataGraph.displayName = "SingleDataGraph";
342
+ DataRecharts.displayName = "DataGraph";
343
+ export default DataRecharts;
src/components/loading-component.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ export default function Loading() {
4
+ return (
5
+ <div
6
+ className="absolute inset-0 flex flex-col items-center justify-center bg-slate-950/70 z-10 text-slate-100 animate-fade-in"
7
+ tabIndex={-1}
8
+ aria-modal="true"
9
+ role="dialog"
10
+ >
11
+ <svg
12
+ className="animate-spin mb-8"
13
+ width="64"
14
+ height="64"
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ xmlns="http://www.w3.org/2000/svg"
18
+ >
19
+ <circle
20
+ className="opacity-25"
21
+ cx="12"
22
+ cy="12"
23
+ r="10"
24
+ stroke="currentColor"
25
+ strokeWidth="4"
26
+ />
27
+ <path
28
+ className="opacity-75"
29
+ fill="currentColor"
30
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
31
+ />
32
+ </svg>
33
+ <h1 className="text-2xl font-bold mb-2">Loading...</h1>
34
+ <p className="text-slate-400">preparing data & videos</p>
35
+ </div>
36
+ );
37
+ }
src/components/playback-bar.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { useTime } from "../context/time-context";
3
+ import {
4
+ FaPlay,
5
+ FaPause,
6
+ FaBackward,
7
+ FaForward,
8
+ FaUndoAlt,
9
+ FaArrowDown,
10
+ FaArrowUp,
11
+ } from "react-icons/fa";
12
+
13
+ import { debounce } from "@/utils/debounce";
14
+
15
+ const PlaybackBar: React.FC = () => {
16
+ const { duration, isPlaying, setIsPlaying, currentTime, setCurrentTime } =
17
+ useTime();
18
+
19
+ const sliderActiveRef = React.useRef(false);
20
+ const wasPlayingRef = React.useRef(false);
21
+ const [sliderValue, setSliderValue] = React.useState(currentTime);
22
+
23
+ // Only update sliderValue from context if not dragging
24
+ React.useEffect(() => {
25
+ if (!sliderActiveRef.current) {
26
+ setSliderValue(currentTime);
27
+ }
28
+ }, [currentTime]);
29
+
30
+ const updateTime = debounce((t: number) => {
31
+ setCurrentTime(t);
32
+ }, 200);
33
+
34
+ const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
35
+ const t = Number(e.target.value);
36
+ setSliderValue(t);
37
+ updateTime(t);
38
+ };
39
+
40
+ const handleSliderMouseDown = () => {
41
+ sliderActiveRef.current = true;
42
+ wasPlayingRef.current = isPlaying;
43
+ setIsPlaying(false);
44
+ };
45
+
46
+ const handleSliderMouseUp = () => {
47
+ sliderActiveRef.current = false;
48
+ setCurrentTime(sliderValue); // Snap to final value
49
+ if (wasPlayingRef.current) {
50
+ setIsPlaying(true);
51
+ }
52
+ // If it was paused before, keep it paused
53
+ };
54
+
55
+ return (
56
+ <div className="flex items-center gap-4 w-full max-w-4xl mx-auto sticky bottom-0 bg-slate-900/95 px-4 py-3 rounded-3xl mt-auto">
57
+ <button
58
+ title="Jump backward 5 seconds"
59
+ onClick={() => setCurrentTime(Math.max(0, currentTime - 5))}
60
+ className="text-2xl hidden md:block"
61
+ >
62
+ <FaBackward size={24} />
63
+ </button>
64
+ <button
65
+ className={`text-3xl transition-transform ${isPlaying ? "scale-90 opacity-60" : "scale-110"}`}
66
+ title="Play. Toggle with Space"
67
+ onClick={() => setIsPlaying(true)}
68
+ style={{ display: isPlaying ? "none" : "inline-block" }}
69
+ >
70
+ <FaPlay size={24} />
71
+ </button>
72
+ <button
73
+ className={`text-3xl transition-transform ${!isPlaying ? "scale-90 opacity-60" : "scale-110"}`}
74
+ title="Pause. Toggle with Space"
75
+ onClick={() => setIsPlaying(false)}
76
+ style={{ display: !isPlaying ? "none" : "inline-block" }}
77
+ >
78
+ <FaPause size={24} />
79
+ </button>
80
+ <button
81
+ title="Jump forward 5 seconds"
82
+ onClick={() => setCurrentTime(Math.min(duration, currentTime + 5))}
83
+ className="text-2xl hidden md:block"
84
+ >
85
+ <FaForward size={24} />
86
+ </button>
87
+ <button
88
+ title="Rewind from start"
89
+ onClick={() => setCurrentTime(0)}
90
+ className="text-2xl hidden md:block"
91
+ >
92
+ <FaUndoAlt size={24} />
93
+ </button>
94
+ <input
95
+ type="range"
96
+ min={0}
97
+ max={duration}
98
+ step={0.01}
99
+ value={sliderValue}
100
+ onChange={handleSliderChange}
101
+ onMouseDown={handleSliderMouseDown}
102
+ onMouseUp={handleSliderMouseUp}
103
+ onTouchStart={handleSliderMouseDown}
104
+ onTouchEnd={handleSliderMouseUp}
105
+ className="flex-1 mx-2 accent-orange-500 focus:outline-none focus:ring-0"
106
+ aria-label="Seek video"
107
+ />
108
+ <span className="w-16 text-right tabular-nums text-xs text-slate-200 shrink-0">
109
+ {Math.floor(sliderValue)} / {Math.floor(duration)}
110
+ </span>
111
+
112
+ <div className="text-xs text-slate-300 select-none ml-8 flex-col gap-y-0.5 hidden md:flex">
113
+ <p>
114
+ <span className="inline-flex items-center gap-1 font-mono align-middle">
115
+ <span className="px-2 py-0.5 rounded border border-slate-400 bg-slate-800 text-slate-200 text-xs shadow-inner">
116
+ Space
117
+ </span>
118
+ </span>{" "}
119
+ to pause/unpause
120
+ </p>
121
+ <p>
122
+ <span className="inline-flex items-center gap-1 font-mono align-middle">
123
+ <FaArrowUp size={14} />/<FaArrowDown size={14} />
124
+ </span>{" "}
125
+ to previous/next episode
126
+ </p>
127
+ </div>
128
+ </div>
129
+ );
130
+ };
131
+
132
+ export default PlaybackBar;
src/components/side-nav.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import React from "react";
5
+
6
+ interface SidebarProps {
7
+ datasetInfo: any;
8
+ paginatedEpisodes: any[];
9
+ episodeId: any;
10
+ totalPages: number;
11
+ currentPage: number;
12
+ prevPage: () => void;
13
+ nextPage: () => void;
14
+ }
15
+
16
+ const Sidebar: React.FC<SidebarProps> = ({
17
+ datasetInfo,
18
+ paginatedEpisodes,
19
+ episodeId,
20
+ totalPages,
21
+ currentPage,
22
+ prevPage,
23
+ nextPage,
24
+ }) => {
25
+ const [sidebarVisible, setSidebarVisible] = React.useState(true);
26
+ const toggleSidebar = () => setSidebarVisible((prev) => !prev);
27
+
28
+ const sidebarRef = React.useRef<HTMLDivElement>(null);
29
+
30
+ React.useEffect(() => {
31
+ if (!sidebarVisible) return;
32
+ function handleClickOutside(event: MouseEvent) {
33
+ // If click is outside the sidebar nav
34
+ if (
35
+ sidebarRef.current &&
36
+ !sidebarRef.current.contains(event.target as Node)
37
+ ) {
38
+ setTimeout(() => setSidebarVisible(false), 500);
39
+ }
40
+ }
41
+ document.addEventListener("mousedown", handleClickOutside);
42
+ return () => {
43
+ document.removeEventListener("mousedown", handleClickOutside);
44
+ };
45
+ }, [sidebarVisible]);
46
+
47
+ return (
48
+ <div className="flex z-10 min-h-screen absolute md:static" ref={sidebarRef}>
49
+ <nav
50
+ className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words md:max-h-screen w-60 md:shrink ${
51
+ !sidebarVisible ? "hidden" : ""
52
+ }`}
53
+ aria-label="Sidebar navigation"
54
+ >
55
+ <ul>
56
+ <li>Number of samples/frames: {datasetInfo.total_frames}</li>
57
+ <li>Number of episodes: {datasetInfo.total_episodes}</li>
58
+ <li>Frames per second: {datasetInfo.fps}</li>
59
+ </ul>
60
+
61
+ <p>Episodes:</p>
62
+
63
+ {/* episodes menu for medium & large screens */}
64
+ <div className="ml-2 block">
65
+ <ul>
66
+ {paginatedEpisodes.map((episode) => (
67
+ <li key={episode} className="mt-0.5 font-mono text-sm">
68
+ <Link
69
+ href={`./episode_${episode}`}
70
+ className={`underline ${episode === episodeId ? "-ml-1 font-bold" : ""}`}
71
+ >
72
+ Episode {episode}
73
+ </Link>
74
+ </li>
75
+ ))}
76
+ </ul>
77
+
78
+ {totalPages > 1 && (
79
+ <div className="mt-3 flex items-center text-xs">
80
+ <button
81
+ onClick={prevPage}
82
+ className={`mr-2 rounded bg-slate-800 px-2 py-1 ${
83
+ currentPage === 1 ? "cursor-not-allowed opacity-50" : ""
84
+ }`}
85
+ disabled={currentPage === 1}
86
+ >
87
+ « Prev
88
+ </button>
89
+ <span className="mr-2 font-mono">
90
+ {currentPage} / {totalPages}
91
+ </span>
92
+ <button
93
+ onClick={nextPage}
94
+ className={`rounded bg-slate-800 px-2 py-1 ${
95
+ currentPage === totalPages
96
+ ? "cursor-not-allowed opacity-50"
97
+ : ""
98
+ }`}
99
+ disabled={currentPage === totalPages}
100
+ >
101
+ Next »
102
+ </button>
103
+ </div>
104
+ )}
105
+ </div>
106
+ </nav>
107
+ {/* Toggle sidebar button */}
108
+ <button
109
+ className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0"
110
+ onClick={toggleSidebar}
111
+ title="Toggle sidebar"
112
+ >
113
+ <div className="h-10 w-2 rounded-full bg-slate-500"></div>
114
+ </button>
115
+ </div>
116
+ );
117
+ };
118
+
119
+ export default Sidebar;
src/components/videos-player.tsx ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { useTime } from "../context/time-context";
5
+ import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
+
7
+ type VideoInfo = {
8
+ filename: string;
9
+ url: string;
10
+ };
11
+
12
+ type VideoPlayerProps = {
13
+ videosInfo: VideoInfo[];
14
+ onVideosReady?: () => void;
15
+ };
16
+
17
+ export const VideosPlayer = ({
18
+ videosInfo,
19
+ onVideosReady,
20
+ }: VideoPlayerProps) => {
21
+ const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
22
+ const videoRefs = useRef<HTMLVideoElement[]>([]);
23
+ // Hidden/enlarged state and hidden menu
24
+ const [hiddenVideos, setHiddenVideos] = useState<string[]>([]);
25
+ // Find the index of the first visible (not hidden) video
26
+ const firstVisibleIdx = videosInfo.findIndex(
27
+ (video) => !hiddenVideos.includes(video.filename),
28
+ );
29
+ // Count of visible videos
30
+ const visibleCount = videosInfo.filter(
31
+ (video) => !hiddenVideos.includes(video.filename),
32
+ ).length;
33
+ const [enlargedVideo, setEnlargedVideo] = useState<string | null>(null);
34
+ // Track previous hiddenVideos for comparison
35
+ const prevHiddenVideosRef = useRef<string[]>([]);
36
+ const videoContainerRefs = useRef<Record<string, HTMLDivElement | null>>({});
37
+ const [showHiddenMenu, setShowHiddenMenu] = useState(false);
38
+ const hiddenMenuRef = useRef<HTMLDivElement | null>(null);
39
+ const showHiddenBtnRef = useRef<HTMLButtonElement | null>(null);
40
+ const [videoCodecError, setVideoCodecError] = useState(false);
41
+
42
+ // Initialize video refs
43
+ useEffect(() => {
44
+ videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
45
+ }, [videosInfo]);
46
+
47
+ // When videos get unhidden, start playing them if it was playing
48
+ useEffect(() => {
49
+ // Find which videos were just unhidden
50
+ const prevHidden = prevHiddenVideosRef.current;
51
+ const newlyUnhidden = prevHidden.filter(
52
+ (filename) => !hiddenVideos.includes(filename),
53
+ );
54
+ if (newlyUnhidden.length > 0) {
55
+ videosInfo.forEach((video, idx) => {
56
+ if (newlyUnhidden.includes(video.filename)) {
57
+ const ref = videoRefs.current[idx];
58
+ if (ref) {
59
+ ref.currentTime = currentTime;
60
+ if (isPlaying) {
61
+ ref.play().catch(() => {});
62
+ }
63
+ }
64
+ }
65
+ });
66
+ }
67
+ prevHiddenVideosRef.current = hiddenVideos;
68
+ }, [hiddenVideos, isPlaying, videosInfo, currentTime]);
69
+
70
+ // Check video codec support
71
+ useEffect(() => {
72
+ const checkCodecSupport = () => {
73
+ const dummyVideo = document.createElement("video");
74
+ const canPlayVideos = dummyVideo.canPlayType(
75
+ 'video/mp4; codecs="av01.0.05M.08"',
76
+ );
77
+ setVideoCodecError(!canPlayVideos);
78
+ };
79
+
80
+ checkCodecSupport();
81
+ }, []);
82
+
83
+ // Handle play/pause
84
+ useEffect(() => {
85
+ videoRefs.current.forEach((video) => {
86
+ if (video) {
87
+ if (isPlaying) {
88
+ video.play().catch((e) => console.error("Error playing video:", e));
89
+ } else {
90
+ video.pause();
91
+ }
92
+ }
93
+ });
94
+ }, [isPlaying]);
95
+
96
+ // Minimize enlarged video on Escape key
97
+ useEffect(() => {
98
+ if (!enlargedVideo) return;
99
+ const handleKeyDown = (e: KeyboardEvent) => {
100
+ if (e.key === "Escape") {
101
+ setEnlargedVideo(null);
102
+ }
103
+ };
104
+ window.addEventListener("keydown", handleKeyDown);
105
+ // Scroll enlarged video into view
106
+ const ref = videoContainerRefs.current[enlargedVideo];
107
+ if (ref) {
108
+ ref.scrollIntoView();
109
+ }
110
+ return () => {
111
+ window.removeEventListener("keydown", handleKeyDown);
112
+ };
113
+ }, [enlargedVideo]);
114
+
115
+ // Close hidden videos dropdown on outside click
116
+ useEffect(() => {
117
+ if (!showHiddenMenu) return;
118
+ function handleClick(e: MouseEvent) {
119
+ const menu = hiddenMenuRef.current;
120
+ const btn = showHiddenBtnRef.current;
121
+ if (
122
+ menu &&
123
+ !menu.contains(e.target as Node) &&
124
+ btn &&
125
+ !btn.contains(e.target as Node)
126
+ ) {
127
+ setShowHiddenMenu(false);
128
+ }
129
+ }
130
+ document.addEventListener("mousedown", handleClick);
131
+ return () => document.removeEventListener("mousedown", handleClick);
132
+ }, [showHiddenMenu]);
133
+
134
+ // Close dropdown if no hidden videos
135
+ useEffect(() => {
136
+ if (hiddenVideos.length === 0 && showHiddenMenu) {
137
+ setShowHiddenMenu(false);
138
+ }
139
+ // Minimize if enlarged video is hidden
140
+ if (enlargedVideo && hiddenVideos.includes(enlargedVideo)) {
141
+ setEnlargedVideo(null);
142
+ }
143
+ }, [hiddenVideos, showHiddenMenu, enlargedVideo]);
144
+
145
+ // Sync video times
146
+ useEffect(() => {
147
+ videoRefs.current.forEach((video) => {
148
+ if (video && Math.abs(video.currentTime - currentTime) > 0.2) {
149
+ video.currentTime = currentTime;
150
+ }
151
+ });
152
+ }, [currentTime]);
153
+
154
+ // Handle time update
155
+ const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
156
+ const video = e.target as HTMLVideoElement;
157
+ if (video && video.duration) {
158
+ setCurrentTime(video.currentTime);
159
+ }
160
+ };
161
+
162
+ // Handle video ready
163
+ useEffect(() => {
164
+ let videosReadyCount = 0;
165
+ const onCanPlayThrough = () => {
166
+ videosReadyCount += 1;
167
+ if (videosReadyCount === videosInfo.length) {
168
+ if (typeof onVideosReady === "function") {
169
+ onVideosReady();
170
+ setIsPlaying(true);
171
+ }
172
+ }
173
+ };
174
+
175
+ videoRefs.current.forEach((video) => {
176
+ if (video) {
177
+ // If already ready, call the handler immediately
178
+ if (video.readyState >= 4) {
179
+ onCanPlayThrough();
180
+ } else {
181
+ video.addEventListener("canplaythrough", onCanPlayThrough);
182
+ }
183
+ }
184
+ });
185
+
186
+ return () => {
187
+ videoRefs.current.forEach((video) => {
188
+ if (video) {
189
+ video.removeEventListener("canplaythrough", onCanPlayThrough);
190
+ }
191
+ });
192
+ };
193
+ }, []);
194
+
195
+ return (
196
+ <>
197
+ {/* Error message */}
198
+ {videoCodecError && (
199
+ <div className="font-medium text-orange-700">
200
+ <p>
201
+ Videos could NOT play because{" "}
202
+ <a
203
+ href="https://en.wikipedia.org/wiki/AV1"
204
+ target="_blank"
205
+ className="underline"
206
+ >
207
+ AV1
208
+ </a>{" "}
209
+ decoding is not available on your browser.
210
+ </p>
211
+ <ul className="list-inside list-decimal">
212
+ <li>
213
+ If iPhone:{" "}
214
+ <span className="italic">
215
+ It is supported with A17 chip or higher.
216
+ </span>
217
+ </li>
218
+ <li>
219
+ If Mac with Safari:{" "}
220
+ <span className="italic">
221
+ It is supported on most browsers except Safari with M1 chip or
222
+ higher and on Safari with M3 chip or higher.
223
+ </span>
224
+ </li>
225
+ <li>
226
+ Other:{" "}
227
+ <span className="italic">
228
+ Contact the maintainers on LeRobot discord channel:
229
+ </span>
230
+ <a
231
+ href="https://discord.com/invite/s3KuuzsPFb"
232
+ target="_blank"
233
+ className="underline"
234
+ >
235
+ https://discord.com/invite/s3KuuzsPFb
236
+ </a>
237
+ </li>
238
+ </ul>
239
+ </div>
240
+ )}
241
+
242
+ {/* Show Hidden Videos Button */}
243
+ {hiddenVideos.length > 0 && (
244
+ <div className="relative">
245
+ <button
246
+ ref={showHiddenBtnRef}
247
+ className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm text-slate-100 hover:bg-slate-700 border border-slate-500"
248
+ onClick={() => setShowHiddenMenu((prev) => !prev)}
249
+ >
250
+ <FaEye /> Show Hidden Videos ({hiddenVideos.length})
251
+ </button>
252
+ {showHiddenMenu && (
253
+ <div
254
+ ref={hiddenMenuRef}
255
+ className="absolute left-0 mt-2 w-max rounded border border-slate-500 bg-slate-900 shadow-lg p-2 z-50"
256
+ >
257
+ <div className="mb-2 text-xs text-slate-300">
258
+ Restore hidden videos:
259
+ </div>
260
+ {hiddenVideos.map((filename) => (
261
+ <button
262
+ key={filename}
263
+ className="block w-full text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-100"
264
+ onClick={() =>
265
+ setHiddenVideos((prev: string[]) =>
266
+ prev.filter((v: string) => v !== filename),
267
+ )
268
+ }
269
+ >
270
+ {filename}
271
+ </button>
272
+ ))}
273
+ </div>
274
+ )}
275
+ </div>
276
+ )}
277
+
278
+ {/* Videos */}
279
+ <div className="flex flex-wrap gap-x-2 gap-y-6">
280
+ {videosInfo.map((video, idx) => {
281
+ if (hiddenVideos.includes(video.filename) || videoCodecError)
282
+ return null;
283
+ const isEnlarged = enlargedVideo === video.filename;
284
+ return (
285
+ <div
286
+ key={video.filename}
287
+ ref={(el) => {
288
+ videoContainerRefs.current[video.filename] = el;
289
+ }}
290
+ className={`${isEnlarged ? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center" : "max-w-96"}`}
291
+ style={isEnlarged ? { height: "100vh", width: "100vw" } : {}}
292
+ >
293
+ <p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between">
294
+ <span>{video.filename}</span>
295
+ <span className="flex gap-1">
296
+ <button
297
+ title={isEnlarged ? "Minimize" : "Enlarge"}
298
+ className="ml-2 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
299
+ onClick={() =>
300
+ setEnlargedVideo(isEnlarged ? null : video.filename)
301
+ }
302
+ >
303
+ {isEnlarged ? <FaCompress /> : <FaExpand />}
304
+ </button>
305
+ <button
306
+ title="Hide Video"
307
+ className="ml-1 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
308
+ onClick={() =>
309
+ setHiddenVideos((prev: string[]) => [
310
+ ...prev,
311
+ video.filename,
312
+ ])
313
+ }
314
+ disabled={visibleCount === 1}
315
+ >
316
+ <FaTimes />
317
+ </button>
318
+ </span>
319
+ </p>
320
+ <video
321
+ ref={(el) => {
322
+ if (el) videoRefs.current[idx] = el;
323
+ }}
324
+ muted
325
+ loop
326
+ className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`}
327
+ onTimeUpdate={
328
+ idx === firstVisibleIdx ? handleTimeUpdate : undefined
329
+ }
330
+ style={isEnlarged ? { zIndex: 41 } : {}}
331
+ >
332
+ <source src={video.url} type="video/mp4" />
333
+ Your browser does not support the video tag.
334
+ </video>
335
+ </div>
336
+ );
337
+ })}
338
+ </div>
339
+ </>
340
+ );
341
+ };
342
+
343
+ export default VideosPlayer;
src/context/time-context.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ } from "react";
8
+
9
+ // The shape of our context
10
+ type TimeContextType = {
11
+ currentTime: number;
12
+ setCurrentTime: (t: number) => void;
13
+ subscribe: (cb: (t: number) => void) => () => void;
14
+ isPlaying: boolean;
15
+ setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>;
16
+ duration: number;
17
+ setDuration: React.Dispatch<React.SetStateAction<number>>;
18
+ };
19
+
20
+ const TimeContext = createContext<TimeContextType | undefined>(undefined);
21
+
22
+ export const useTime = () => {
23
+ const ctx = useContext(TimeContext);
24
+ if (!ctx) throw new Error("useTime must be used within a TimeProvider");
25
+ return ctx;
26
+ };
27
+
28
+ export const TimeProvider: React.FC<{
29
+ children: React.ReactNode;
30
+ duration: number;
31
+ }> = ({ children, duration: initialDuration }) => {
32
+ const [currentTime, setCurrentTime] = useState(0);
33
+ const [isPlaying, setIsPlaying] = useState(false);
34
+ const [duration, setDuration] = useState(initialDuration);
35
+ const listeners = useRef<Set<(t: number) => void>>(new Set());
36
+
37
+ // Call this to update time and notify all listeners
38
+ const updateTime = useCallback((t: number) => {
39
+ setCurrentTime(t);
40
+ listeners.current.forEach((fn) => fn(t));
41
+ }, []);
42
+
43
+ // Components can subscribe to time changes (for imperative updates)
44
+ const subscribe = useCallback((cb: (t: number) => void) => {
45
+ listeners.current.add(cb);
46
+ return () => listeners.current.delete(cb);
47
+ }, []);
48
+
49
+ return (
50
+ <TimeContext.Provider
51
+ value={{
52
+ currentTime,
53
+ setCurrentTime: updateTime,
54
+ subscribe,
55
+ isPlaying,
56
+ setIsPlaying,
57
+ duration,
58
+ setDuration,
59
+ }}
60
+ >
61
+ {children}
62
+ </TimeContext.Provider>
63
+ );
64
+ };
src/utils/debounce.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export function debounce<F extends (...args: any[]) => any>(
2
+ func: F,
3
+ waitFor: number,
4
+ ): (...args: Parameters<F>) => void {
5
+ let timeoutId: number;
6
+ return (...args: Parameters<F>) => {
7
+ clearTimeout(timeoutId);
8
+ timeoutId = window.setTimeout(() => func(...args), waitFor);
9
+ };
10
+ }
src/utils/parquetUtils.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { parquetRead } from "hyparquet";
2
+
3
+ export interface DatasetMetadata {
4
+ codebase_version: string;
5
+ robot_type: string;
6
+ total_episodes: number;
7
+ total_frames: number;
8
+ total_tasks: number;
9
+ total_videos: number;
10
+ total_chunks: number;
11
+ chunks_size: number;
12
+ fps: number;
13
+ splits: Record<string, string>;
14
+ data_path: string;
15
+ video_path: string;
16
+ features: Record<
17
+ string,
18
+ {
19
+ dtype: string;
20
+ shape: any[];
21
+ names: any[] | Record<string, any> | null;
22
+ info?: Record<string, any>;
23
+ }
24
+ >;
25
+ }
26
+
27
+ export async function fetchJson<T>(url: string): Promise<T> {
28
+ const res = await fetch(url);
29
+ if (!res.ok) {
30
+ throw new Error(
31
+ `Failed to fetch JSON ${url}: ${res.status} ${res.statusText}`,
32
+ );
33
+ }
34
+ return res.json() as Promise<T>;
35
+ }
36
+
37
+ export function formatStringWithVars(
38
+ format: string,
39
+ vars: Record<string, any>,
40
+ ): string {
41
+ return format.replace(/{(\w+)(?::\d+d)?}/g, (_, key) => vars[key]);
42
+ }
43
+
44
+ // Fetch and parse the Parquet file
45
+ export async function fetchParquetFile(url: string): Promise<ArrayBuffer> {
46
+ const res = await fetch(url);
47
+ return res.arrayBuffer();
48
+ }
49
+
50
+ // Read specific columns from the Parquet file
51
+ export async function readParquetColumn(
52
+ fileBuffer: ArrayBuffer,
53
+ columns: string[],
54
+ ): Promise<any[]> {
55
+ return new Promise((resolve) => {
56
+ parquetRead({
57
+ file: fileBuffer,
58
+ columns,
59
+ onComplete: (data: any[]) => resolve(data),
60
+ });
61
+ });
62
+ }
63
+
64
+ // Convert a 2D array to a CSV string
65
+ export function arrayToCSV(data: (number | string)[][]): string {
66
+ return data.map((row) => row.join(",")).join("\n");
67
+ }
68
+
69
+ // Get rows from the current frame data
70
+ export function getRows(currentFrameData: any[], columns: any[]) {
71
+ if (!currentFrameData || currentFrameData.length === 0) {
72
+ return [];
73
+ }
74
+
75
+ const rows = [];
76
+ const nRows = Math.max(...columns.map((column) => column.value.length));
77
+ let rowIndex = 0;
78
+
79
+ while (rowIndex < nRows) {
80
+ const row = [];
81
+ // number of states may NOT match number of actions. In this case, we null-pad the 2D array
82
+ const nullCell = { isNull: true };
83
+ // row consists of [state value, action value]
84
+ let idx = rowIndex;
85
+
86
+ for (const column of columns) {
87
+ const nColumn = column.value.length;
88
+ row.push(rowIndex < nColumn ? currentFrameData[idx] : nullCell);
89
+ idx += nColumn; // because currentFrameData = [state0, state1, ..., stateN, action0, action1, ..., actionN]
90
+ }
91
+
92
+ rowIndex += 1;
93
+ rows.push(row);
94
+ }
95
+
96
+ return rows;
97
+ }
src/utils/pick.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Return copy of object, only keeping whitelisted properties.
3
+ *
4
+ * This doesn't add {p: undefined} anymore, for props not in the o object.
5
+ */
6
+ export function pick<T, K extends keyof T>(
7
+ o: T,
8
+ props: K[] | ReadonlyArray<K>,
9
+ ): Pick<T, K> {
10
+ // inspired by stackoverflow.com/questions/25553910/one-liner-to-take-some-properties-from-object-in-es-6
11
+ return Object.assign(
12
+ {},
13
+ ...props.map((prop) => {
14
+ if (o[prop] !== undefined) {
15
+ return { [prop]: o[prop] };
16
+ }
17
+ }),
18
+ );
19
+ }
src/utils/postParentMessage.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Utility to post a message to the parent window with custom URLSearchParams
2
+ export function postParentMessageWithParams(
3
+ setParams: (params: URLSearchParams) => void,
4
+ ) {
5
+ const parentOrigin = "https://huggingface.co";
6
+ const searchParams = new URLSearchParams();
7
+ setParams(searchParams);
8
+ window.parent.postMessage(
9
+ { queryString: searchParams.toString() },
10
+ parentOrigin,
11
+ );
12
+ }
src/utils/versionUtils.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utility functions for checking dataset version compatibility
3
+ */
4
+
5
+ const DATASET_URL = process.env.DATASET_URL || "https://huggingface.co/datasets";
6
+
7
+ /**
8
+ * Checks if a specific version/branch exists for a dataset
9
+ */
10
+ async function checkVersionExists(repoId: string, version: string): Promise<boolean> {
11
+ try {
12
+ const testUrl = `${DATASET_URL}/${repoId}/resolve/${version}/meta/info.json`;
13
+
14
+ // Try a simple GET request with a timeout
15
+ const controller = new AbortController();
16
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
17
+
18
+ const response = await fetch(testUrl, {
19
+ method: "GET",
20
+ cache: "no-store",
21
+ signal: controller.signal
22
+ });
23
+
24
+ clearTimeout(timeoutId);
25
+
26
+ // Check if it's a successful response
27
+ if (response.ok) {
28
+ // Try to parse a bit of the JSON to make sure it's valid
29
+ try {
30
+ const text = await response.text();
31
+ const data = JSON.parse(text);
32
+ return !!data.features; // Only return true if it has features
33
+ } catch (parseError) {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ return false;
39
+ } catch (error) {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Determines the best available version for a dataset.
46
+ * Prefers v2.1, falls back to v2.0, or throws an error if neither exists.
47
+ */
48
+ export async function getDatasetVersion(repoId: string): Promise<string> {
49
+ // Check for v2.1 first
50
+ if (await checkVersionExists(repoId, "v2.1")) {
51
+ return "v2.1";
52
+ }
53
+
54
+ // Fall back to v2.0
55
+ if (await checkVersionExists(repoId, "v2.0")) {
56
+ return "v2.0";
57
+ }
58
+
59
+ // If neither v2.1 nor v2.0 exists, throw an error
60
+ throw new Error(
61
+ `Dataset ${repoId} is not compatible with this visualizer. ` +
62
+ "This tool only works with dataset version 2.x (v2.1 or v2.0). " +
63
+ "Please use a compatible dataset version."
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Constructs a versioned URL for dataset resources
69
+ */
70
+ export function buildVersionedUrl(repoId: string, version: string, path: string): string {
71
+ return `${DATASET_URL}/${repoId}/resolve/${version}/${path}`;
72
+ }
73
+
74
+ /**
75
+ * Debug function to test version checking manually
76
+ */
77
+ export async function testVersionCheck(repoId: string): Promise<void> {
78
+ console.log(`Testing version check for: ${repoId}`);
79
+ try {
80
+ const version = await getDatasetVersion(repoId);
81
+ console.log(`Success! Best version found: ${version}`);
82
+ } catch (error) {
83
+ console.error(`Failed:`, error);
84
+ }
85
+ }
tsconfig.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
+ "exclude": ["node_modules"]
27
+ }