pepijn223 HF Staff commited on
Commit
6346799
·
unverified ·
1 Parent(s): a990603

Add stats, 3d viewer, begin/end image visualization

Browse files
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -10,12 +10,16 @@
10
  "format": "prettier --write ."
11
  },
12
  "dependencies": {
 
 
 
13
  "hyparquet": "^1.12.1",
14
  "next": "15.3.6",
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",
 
10
  "format": "prettier --write ."
11
  },
12
  "dependencies": {
13
+ "@react-three/drei": "^10.7.7",
14
+ "@react-three/fiber": "^9.5.0",
15
+ "@types/three": "^0.182.0",
16
  "hyparquet": "^1.12.1",
17
  "next": "15.3.6",
18
  "react": "^19.0.0",
19
  "react-dom": "^19.0.0",
20
  "react-icons": "^5.5.0",
21
+ "recharts": "^2.15.3",
22
+ "three": "^0.182.0"
23
  },
24
  "devDependencies": {
25
  "@eslint/eslintrc": "^3",
src/app/[org]/[dataset]/[episode]/actions.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { getDatasetVersionAndInfo } from "@/utils/versionUtils";
4
+ import type { DatasetMetadata } from "@/utils/parquetUtils";
5
+ import {
6
+ loadAllEpisodeLengthsV3,
7
+ loadAllEpisodeFrameInfo,
8
+ type EpisodeLengthStats,
9
+ type EpisodeFramesData,
10
+ } from "./fetch-data";
11
+
12
+ export async function fetchEpisodeLengthStats(
13
+ org: string,
14
+ dataset: string,
15
+ ): Promise<EpisodeLengthStats | null> {
16
+ const repoId = `${org}/${dataset}`;
17
+ const { version, info } = await getDatasetVersionAndInfo(repoId);
18
+ if (version !== "v3.0") return null;
19
+ return loadAllEpisodeLengthsV3(repoId, version, info.fps);
20
+ }
21
+
22
+ export async function fetchEpisodeFrames(
23
+ org: string,
24
+ dataset: string,
25
+ ): Promise<EpisodeFramesData> {
26
+ const repoId = `${org}/${dataset}`;
27
+ const { version, info } = await getDatasetVersionAndInfo(repoId);
28
+ return loadAllEpisodeFrameInfo(repoId, version, info as unknown as DatasetMetadata);
29
+ }
30
+
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx CHANGED
@@ -1,6 +1,6 @@
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 { SimpleVideosPlayer } from "@/components/simple-videos-player";
@@ -8,8 +8,23 @@ 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
- import { getAdjacentEpisodesVideoInfo, type EpisodeData } from "./fetch-data";
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  export default function EpisodeViewer({
15
  data,
@@ -63,7 +78,47 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
63
  const router = useRouter();
64
  const searchParams = useSearchParams();
65
 
66
- // State
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  // Use context for time sync
68
  const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime();
69
 
@@ -191,85 +246,171 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
191
  };
192
 
193
  return (
194
- <div className="flex h-screen max-h-screen bg-slate-950 text-gray-200">
195
- {/* Sidebar */}
196
- <Sidebar
197
- datasetInfo={datasetInfo}
198
- paginatedEpisodes={paginatedEpisodes}
199
- episodeId={episodeId}
200
- totalPages={totalPages}
201
- currentPage={currentPage}
202
- prevPage={prevPage}
203
- nextPage={nextPage}
204
- />
205
-
206
- {/* Content */}
207
- <div
208
- className={`flex max-h-screen flex-col gap-4 p-4 md:flex-1 relative ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}
209
- >
210
- {isLoading && <Loading />}
211
-
212
- <div className="flex items-center justify-start my-4">
213
- <a
214
- href="https://github.com/huggingface/lerobot"
215
- target="_blank"
216
- className="block"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  >
218
- <img
219
- src="https://github.com/huggingface/lerobot/raw/main/media/readme/lerobot-logo-thumbnail.png"
220
- alt="LeRobot Logo"
221
- className="w-32"
222
- />
223
- </a>
224
-
225
- <div>
226
- <a
227
- href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
228
- target="_blank"
229
- >
230
- <p className="text-lg font-semibold">{datasetInfo.repoId}</p>
231
- </a>
232
-
233
- <p className="font-mono text-lg font-semibold">
234
- episode {episodeId}
235
- </p>
236
- </div>
237
- </div>
238
 
239
- {/* Videos */}
240
- {videosInfo.length && (
241
- <SimpleVideosPlayer
242
- videosInfo={videosInfo}
243
- onVideosReady={() => setVideosReady(true)}
 
 
 
 
 
 
 
244
  />
245
  )}
246
 
247
- {/* Language Instruction */}
248
- {task && (
249
- <div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
250
- <p className="text-slate-300">
251
- <span className="font-semibold text-slate-100">Language Instruction:</span>
252
- </p>
253
- <div className="mt-2 text-slate-300">
254
- {task.split('\n').map((instruction: string, index: number) => (
255
- <p key={index} className="mb-1">
256
- {instruction}
257
- </p>
258
- ))}
259
- </div>
260
- </div>
261
- )}
262
 
263
- {/* Graph */}
264
- <div className="mb-4">
265
- <DataRecharts
266
- data={chartDataGroups}
267
- onChartsReady={() => setChartsReady(true)}
268
- />
 
 
 
 
 
 
 
 
269
 
270
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
- <PlaybackBar />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  </div>
274
  </div>
275
  );
 
1
  "use client";
2
 
3
+ import { useState, useEffect, useRef, lazy, Suspense } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import { postParentMessageWithParams } from "@/utils/postParentMessage";
6
  import { SimpleVideosPlayer } from "@/components/simple-videos-player";
 
8
  import PlaybackBar from "@/components/playback-bar";
9
  import { TimeProvider, useTime } from "@/context/time-context";
10
  import Sidebar from "@/components/side-nav";
11
+ import StatsPanel from "@/components/stats-panel";
12
+ import OverviewPanel from "@/components/overview-panel";
13
  import Loading from "@/components/loading-component";
14
+ import { isSO101Robot } from "@/lib/so101-robot";
15
+ import {
16
+ getAdjacentEpisodesVideoInfo,
17
+ computeColumnMinMax,
18
+ type EpisodeData,
19
+ type ColumnMinMax,
20
+ type EpisodeLengthStats,
21
+ type EpisodeFramesData,
22
+ } from "./fetch-data";
23
+ import { fetchEpisodeLengthStats, fetchEpisodeFrames } from "./actions";
24
+
25
+ const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
26
+
27
+ type ActiveTab = "episodes" | "statistics" | "frames" | "urdf";
28
 
29
  export default function EpisodeViewer({
30
  data,
 
78
  const router = useRouter();
79
  const searchParams = useSearchParams();
80
 
81
+ // Tab state & lazy stats
82
+ const [activeTab, setActiveTab] = useState<ActiveTab>("episodes");
83
+ const [columnMinMax, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
84
+ const [episodeLengthStats, setEpisodeLengthStats] = useState<EpisodeLengthStats | null>(null);
85
+ const [statsLoading, setStatsLoading] = useState(false);
86
+ const statsLoadedRef = useRef(false);
87
+ const [episodeFramesData, setEpisodeFramesData] = useState<EpisodeFramesData | null>(null);
88
+ const [framesLoading, setFramesLoading] = useState(false);
89
+ const framesLoadedRef = useRef(false);
90
+
91
+ const loadStats = () => {
92
+ if (statsLoadedRef.current) return;
93
+ statsLoadedRef.current = true;
94
+ setStatsLoading(true);
95
+ setColumnMinMax(computeColumnMinMax(data.chartDataGroups));
96
+ if (org && dataset) {
97
+ fetchEpisodeLengthStats(org, dataset)
98
+ .then((result) => setEpisodeLengthStats(result))
99
+ .catch(() => {})
100
+ .finally(() => setStatsLoading(false));
101
+ } else {
102
+ setStatsLoading(false);
103
+ }
104
+ };
105
+
106
+ const loadFrames = () => {
107
+ if (framesLoadedRef.current || !org || !dataset) return;
108
+ framesLoadedRef.current = true;
109
+ setFramesLoading(true);
110
+ fetchEpisodeFrames(org, dataset)
111
+ .then(setEpisodeFramesData)
112
+ .catch(() => setEpisodeFramesData({ cameras: [], framesByCamera: {} }))
113
+ .finally(() => setFramesLoading(false));
114
+ };
115
+
116
+ const handleTabChange = (tab: ActiveTab) => {
117
+ setActiveTab(tab);
118
+ if (tab === "statistics") loadStats();
119
+ if (tab === "frames") loadFrames();
120
+ };
121
+
122
  // Use context for time sync
123
  const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime();
124
 
 
246
  };
247
 
248
  return (
249
+ <div className="flex flex-col h-screen max-h-screen bg-slate-950 text-gray-200">
250
+ {/* Top tab bar */}
251
+ <div className="flex items-center border-b border-slate-700 bg-slate-900 shrink-0">
252
+ <button
253
+ className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
254
+ activeTab === "episodes"
255
+ ? "text-orange-400"
256
+ : "text-slate-400 hover:text-slate-200"
257
+ }`}
258
+ onClick={() => handleTabChange("episodes")}
259
+ >
260
+ Episodes
261
+ {activeTab === "episodes" && (
262
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
263
+ )}
264
+ </button>
265
+ <button
266
+ className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
267
+ activeTab === "statistics"
268
+ ? "text-orange-400"
269
+ : "text-slate-400 hover:text-slate-200"
270
+ }`}
271
+ onClick={() => handleTabChange("statistics")}
272
+ >
273
+ Statistics
274
+ {activeTab === "statistics" && (
275
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
276
+ )}
277
+ </button>
278
+ <button
279
+ className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
280
+ activeTab === "frames"
281
+ ? "text-orange-400"
282
+ : "text-slate-400 hover:text-slate-200"
283
+ }`}
284
+ onClick={() => handleTabChange("frames")}
285
+ >
286
+ Frames
287
+ {activeTab === "frames" && (
288
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
289
+ )}
290
+ </button>
291
+ {isSO101Robot(datasetInfo.robot_type) && (
292
+ <button
293
+ className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
294
+ activeTab === "urdf"
295
+ ? "text-orange-400"
296
+ : "text-slate-400 hover:text-slate-200"
297
+ }`}
298
+ onClick={() => handleTabChange("urdf")}
299
  >
300
+ 3D Replay
301
+ {activeTab === "urdf" && (
302
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
303
+ )}
304
+ </button>
305
+ )}
306
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
+ {/* Body: sidebar + content */}
309
+ <div className="flex flex-1 min-h-0">
310
+ {/* Sidebar — only on Episodes tab */}
311
+ {activeTab === "episodes" && (
312
+ <Sidebar
313
+ datasetInfo={datasetInfo}
314
+ paginatedEpisodes={paginatedEpisodes}
315
+ episodeId={episodeId}
316
+ totalPages={totalPages}
317
+ currentPage={currentPage}
318
+ prevPage={prevPage}
319
+ nextPage={nextPage}
320
  />
321
  )}
322
 
323
+ {/* Main content */}
324
+ <div
325
+ className={`flex flex-col gap-4 p-4 flex-1 relative ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}
326
+ >
327
+ {isLoading && <Loading />}
 
 
 
 
 
 
 
 
 
 
328
 
329
+ {activeTab === "episodes" && (
330
+ <>
331
+ <div className="flex items-center justify-start my-4">
332
+ <a
333
+ href="https://github.com/huggingface/lerobot"
334
+ target="_blank"
335
+ className="block"
336
+ >
337
+ <img
338
+ src="https://github.com/huggingface/lerobot/raw/main/media/readme/lerobot-logo-thumbnail.png"
339
+ alt="LeRobot Logo"
340
+ className="w-32"
341
+ />
342
+ </a>
343
 
344
+ <div>
345
+ <a
346
+ href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
347
+ target="_blank"
348
+ >
349
+ <p className="text-lg font-semibold">{datasetInfo.repoId}</p>
350
+ </a>
351
+
352
+ <p className="font-mono text-lg font-semibold">
353
+ episode {episodeId}
354
+ </p>
355
+ </div>
356
+ </div>
357
+
358
+ {/* Videos */}
359
+ {videosInfo.length > 0 && (
360
+ <SimpleVideosPlayer
361
+ videosInfo={videosInfo}
362
+ onVideosReady={() => setVideosReady(true)}
363
+ />
364
+ )}
365
+
366
+ {/* Language Instruction */}
367
+ {task && (
368
+ <div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
369
+ <p className="text-slate-300">
370
+ <span className="font-semibold text-slate-100">Language Instruction:</span>
371
+ </p>
372
+ <div className="mt-2 text-slate-300">
373
+ {task.split('\n').map((instruction: string, index: number) => (
374
+ <p key={index} className="mb-1">
375
+ {instruction}
376
+ </p>
377
+ ))}
378
+ </div>
379
+ </div>
380
+ )}
381
 
382
+ {/* Graph */}
383
+ <div className="mb-4">
384
+ <DataRecharts
385
+ data={chartDataGroups}
386
+ onChartsReady={() => setChartsReady(true)}
387
+ />
388
+ </div>
389
+
390
+ <PlaybackBar />
391
+ </>
392
+ )}
393
+
394
+ {activeTab === "statistics" && (
395
+ <StatsPanel
396
+ datasetInfo={datasetInfo}
397
+ episodeId={episodeId}
398
+ columnMinMax={columnMinMax}
399
+ episodeLengthStats={episodeLengthStats}
400
+ loading={statsLoading}
401
+ />
402
+ )}
403
+
404
+ {activeTab === "frames" && (
405
+ <OverviewPanel data={episodeFramesData} loading={framesLoading} />
406
+ )}
407
+
408
+ {activeTab === "urdf" && (
409
+ <Suspense fallback={<Loading />}>
410
+ <URDFViewer data={data} />
411
+ </Suspense>
412
+ )}
413
+ </div>
414
  </div>
415
  </div>
416
  );
src/app/[org]/[dataset]/[episode]/fetch-data.ts CHANGED
@@ -18,20 +18,62 @@ export type VideoInfo = {
18
  segmentDuration?: number;
19
  };
20
 
 
 
21
  export type DatasetDisplayInfo = {
22
  repoId: string;
23
  total_frames: number;
24
  total_episodes: number;
25
  fps: number;
 
 
 
 
 
26
  };
27
 
28
  export type ChartRow = Record<string, number | Record<string, number>>;
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  export type EpisodeData = {
31
  datasetInfo: DatasetDisplayInfo;
32
  episodeId: number;
33
  videosInfo: VideoInfo[];
34
  chartDataGroups: ChartRow[][];
 
35
  episodes: number[];
36
  ignoredColumns: string[];
37
  duration: number;
@@ -107,6 +149,21 @@ export async function getEpisodeData(
107
  ? await getEpisodeDataV3(repoId, version, info, episodeId)
108
  : await getEpisodeDataV2(repoId, version, info, episodeId);
109
  console.timeEnd(`[perf] getEpisodeData (${version})`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  return result;
111
  } catch (err) {
112
  console.error("Error loading episode data:", err);
@@ -181,12 +238,16 @@ async function getEpisodeDataV2(
181
  ): Promise<EpisodeData> {
182
  const episode_chunk = Math.floor(0 / 1000);
183
 
184
- // Dataset information
185
- const datasetInfo = {
186
  repoId,
187
  total_frames: info.total_frames,
188
  total_episodes: info.total_episodes,
189
  fps: info.fps,
 
 
 
 
 
190
  };
191
 
192
  // Generate list of episodes
@@ -440,6 +501,7 @@ async function getEpisodeDataV2(
440
  episodeId,
441
  videosInfo,
442
  chartDataGroups,
 
443
  episodes,
444
  ignoredColumns,
445
  duration,
@@ -454,15 +516,18 @@ async function getEpisodeDataV3(
454
  info: DatasetMetadata,
455
  episodeId: number,
456
  ): Promise<EpisodeData> {
457
- // Create dataset info structure (like v2.x)
458
- const datasetInfo = {
459
  repoId,
460
  total_frames: info.total_frames,
461
  total_episodes: info.total_episodes,
462
  fps: info.fps,
 
 
 
 
 
463
  };
464
 
465
- // Generate episodes list based on total_episodes from dataset info
466
  const episodes = Array.from({ length: info.total_episodes }, (_, i) => i);
467
 
468
  // Load episode metadata to get timestamps for episode 0
@@ -472,17 +537,17 @@ async function getEpisodeDataV3(
472
  const videosInfo = extractVideoInfoV3WithSegmentation(repoId, version, info, episodeMetadata);
473
 
474
  // Load episode data for charts
475
- const { chartDataGroups, ignoredColumns, task } = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
476
 
477
- // Calculate duration from episode length and FPS if available
478
  const duration = episodeMetadata.length ? episodeMetadata.length / info.fps :
479
  (episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp);
480
-
481
  return {
482
  datasetInfo,
483
  episodeId,
484
  videosInfo,
485
  chartDataGroups,
 
486
  episodes,
487
  ignoredColumns,
488
  duration,
@@ -496,7 +561,7 @@ async function loadEpisodeDataV3(
496
  version: string,
497
  info: DatasetMetadata,
498
  episodeMetadata: EpisodeMetadataV3,
499
- ): Promise<{ chartDataGroups: ChartRow[][]; ignoredColumns: string[]; task?: string }> {
500
  // Build data file path using chunk and file indices
501
  const dataChunkIndex = episodeMetadata.data_chunk_index || 0;
502
  const dataFileIndex = episodeMetadata.data_file_index || 0;
@@ -526,11 +591,11 @@ async function loadEpisodeDataV3(
526
  const episodeData = fullData.slice(localFromIndex, localToIndex);
527
 
528
  if (episodeData.length === 0) {
529
- return { chartDataGroups: [], ignoredColumns: [], task: undefined };
530
  }
531
 
532
  // Convert to the same format as v2.x for compatibility with existing chart code
533
- const { chartDataGroups, ignoredColumns } = processEpisodeDataForCharts(episodeData, info, episodeMetadata);
534
 
535
  // First check for language_instruction fields in the data (preferred)
536
  let task: string | undefined;
@@ -586,9 +651,9 @@ async function loadEpisodeDataV3(
586
  }
587
  }
588
 
589
- return { chartDataGroups, ignoredColumns, task };
590
  } catch {
591
- return { chartDataGroups: [], ignoredColumns: [], task: undefined };
592
  }
593
  }
594
 
@@ -597,7 +662,7 @@ function processEpisodeDataForCharts(
597
  episodeData: Record<string, unknown>[],
598
  info: DatasetMetadata,
599
  episodeMetadata?: EpisodeMetadataV3,
600
- ): { chartDataGroups: ChartRow[][]; ignoredColumns: string[] } {
601
 
602
  // Get numeric column features
603
  const columnNames = Object.entries(info.features)
@@ -854,7 +919,7 @@ function processEpisodeDataForCharts(
854
  chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
855
  );
856
 
857
- return { chartDataGroups, ignoredColumns };
858
  }
859
 
860
 
@@ -1054,6 +1119,224 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
1054
 
1055
 
1056
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  // Safe wrapper for UI error display
1058
  export async function getEpisodeDataSafe(
1059
  org: string,
 
18
  segmentDuration?: number;
19
  };
20
 
21
+ export type CameraInfo = { name: string; width: number; height: number };
22
+
23
  export type DatasetDisplayInfo = {
24
  repoId: string;
25
  total_frames: number;
26
  total_episodes: number;
27
  fps: number;
28
+ robot_type: string | null;
29
+ codebase_version: string;
30
+ total_tasks: number;
31
+ dataset_size_mb: number;
32
+ cameras: CameraInfo[];
33
  };
34
 
35
  export type ChartRow = Record<string, number | Record<string, number>>;
36
 
37
+ export type ColumnMinMax = {
38
+ column: string;
39
+ min: number;
40
+ max: number;
41
+ };
42
+
43
+ export type EpisodeLengthInfo = {
44
+ episodeIndex: number;
45
+ lengthSeconds: number;
46
+ frames: number;
47
+ };
48
+
49
+ export type EpisodeLengthStats = {
50
+ shortestEpisodes: EpisodeLengthInfo[];
51
+ longestEpisodes: EpisodeLengthInfo[];
52
+ allEpisodeLengths: EpisodeLengthInfo[];
53
+ meanEpisodeLength: number;
54
+ medianEpisodeLength: number;
55
+ stdEpisodeLength: number;
56
+ episodeLengthHistogram: { binLabel: string; count: number }[];
57
+ };
58
+
59
+ export type EpisodeFrameInfo = {
60
+ episodeIndex: number;
61
+ videoUrl: string;
62
+ firstFrameTime: number;
63
+ lastFrameTime: number | null; // null = seek to video.duration on client
64
+ };
65
+
66
+ export type EpisodeFramesData = {
67
+ cameras: string[];
68
+ framesByCamera: Record<string, EpisodeFrameInfo[]>;
69
+ };
70
+
71
  export type EpisodeData = {
72
  datasetInfo: DatasetDisplayInfo;
73
  episodeId: number;
74
  videosInfo: VideoInfo[];
75
  chartDataGroups: ChartRow[][];
76
+ flatChartData: Record<string, number>[];
77
  episodes: number[];
78
  ignoredColumns: string[];
79
  duration: number;
 
149
  ? await getEpisodeDataV3(repoId, version, info, episodeId)
150
  : await getEpisodeDataV2(repoId, version, info, episodeId);
151
  console.timeEnd(`[perf] getEpisodeData (${version})`);
152
+
153
+ // Extract camera resolutions from features
154
+ const cameras: CameraInfo[] = Object.entries(rawInfo.features)
155
+ .filter(([, f]) => f.dtype === "video" && f.shape.length >= 2)
156
+ .map(([name, f]) => ({ name, height: f.shape[0], width: f.shape[1] }));
157
+
158
+ result.datasetInfo = {
159
+ ...result.datasetInfo,
160
+ robot_type: rawInfo.robot_type ?? null,
161
+ codebase_version: rawInfo.codebase_version,
162
+ total_tasks: rawInfo.total_tasks ?? 0,
163
+ dataset_size_mb: Math.round(((rawInfo.data_files_size_in_mb ?? 0) + (rawInfo.video_files_size_in_mb ?? 0)) * 10) / 10,
164
+ cameras,
165
+ };
166
+
167
  return result;
168
  } catch (err) {
169
  console.error("Error loading episode data:", err);
 
238
  ): Promise<EpisodeData> {
239
  const episode_chunk = Math.floor(0 / 1000);
240
 
241
+ const datasetInfo: DatasetDisplayInfo = {
 
242
  repoId,
243
  total_frames: info.total_frames,
244
  total_episodes: info.total_episodes,
245
  fps: info.fps,
246
+ robot_type: null,
247
+ codebase_version: version,
248
+ total_tasks: 0,
249
+ dataset_size_mb: 0,
250
+ cameras: [],
251
  };
252
 
253
  // Generate list of episodes
 
501
  episodeId,
502
  videosInfo,
503
  chartDataGroups,
504
+ flatChartData: chartData,
505
  episodes,
506
  ignoredColumns,
507
  duration,
 
516
  info: DatasetMetadata,
517
  episodeId: number,
518
  ): Promise<EpisodeData> {
519
+ const datasetInfo: DatasetDisplayInfo = {
 
520
  repoId,
521
  total_frames: info.total_frames,
522
  total_episodes: info.total_episodes,
523
  fps: info.fps,
524
+ robot_type: null,
525
+ codebase_version: version,
526
+ total_tasks: 0,
527
+ dataset_size_mb: 0,
528
+ cameras: [],
529
  };
530
 
 
531
  const episodes = Array.from({ length: info.total_episodes }, (_, i) => i);
532
 
533
  // Load episode metadata to get timestamps for episode 0
 
537
  const videosInfo = extractVideoInfoV3WithSegmentation(repoId, version, info, episodeMetadata);
538
 
539
  // Load episode data for charts
540
+ const { chartDataGroups, flatChartData, ignoredColumns, task } = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
541
 
 
542
  const duration = episodeMetadata.length ? episodeMetadata.length / info.fps :
543
  (episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp);
544
+
545
  return {
546
  datasetInfo,
547
  episodeId,
548
  videosInfo,
549
  chartDataGroups,
550
+ flatChartData,
551
  episodes,
552
  ignoredColumns,
553
  duration,
 
561
  version: string,
562
  info: DatasetMetadata,
563
  episodeMetadata: EpisodeMetadataV3,
564
+ ): Promise<{ chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[]; ignoredColumns: string[]; task?: string }> {
565
  // Build data file path using chunk and file indices
566
  const dataChunkIndex = episodeMetadata.data_chunk_index || 0;
567
  const dataFileIndex = episodeMetadata.data_file_index || 0;
 
591
  const episodeData = fullData.slice(localFromIndex, localToIndex);
592
 
593
  if (episodeData.length === 0) {
594
+ return { chartDataGroups: [], flatChartData: [], ignoredColumns: [], task: undefined };
595
  }
596
 
597
  // Convert to the same format as v2.x for compatibility with existing chart code
598
+ const { chartDataGroups, flatChartData, ignoredColumns } = processEpisodeDataForCharts(episodeData, info, episodeMetadata);
599
 
600
  // First check for language_instruction fields in the data (preferred)
601
  let task: string | undefined;
 
651
  }
652
  }
653
 
654
+ return { chartDataGroups, flatChartData, ignoredColumns, task };
655
  } catch {
656
+ return { chartDataGroups: [], flatChartData: [], ignoredColumns: [], task: undefined };
657
  }
658
  }
659
 
 
662
  episodeData: Record<string, unknown>[],
663
  info: DatasetMetadata,
664
  episodeMetadata?: EpisodeMetadataV3,
665
+ ): { chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[]; ignoredColumns: string[] } {
666
 
667
  // Get numeric column features
668
  const columnNames = Object.entries(info.features)
 
919
  chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
920
  );
921
 
922
+ return { chartDataGroups, flatChartData: chartData, ignoredColumns };
923
  }
924
 
925
 
 
1119
 
1120
 
1121
 
1122
+ // ─── Stats computation ───────────────────────────────────────────
1123
+
1124
+ /**
1125
+ * Compute per-column min/max values from the current episode's chart data.
1126
+ */
1127
+ export function computeColumnMinMax(chartDataGroups: ChartRow[][]): ColumnMinMax[] {
1128
+ const stats: Record<string, { min: number; max: number }> = {};
1129
+
1130
+ for (const group of chartDataGroups) {
1131
+ for (const row of group) {
1132
+ for (const [key, value] of Object.entries(row)) {
1133
+ if (key === "timestamp") continue;
1134
+ if (typeof value === "number" && isFinite(value)) {
1135
+ if (!stats[key]) {
1136
+ stats[key] = { min: value, max: value };
1137
+ } else {
1138
+ if (value < stats[key].min) stats[key].min = value;
1139
+ if (value > stats[key].max) stats[key].max = value;
1140
+ }
1141
+ } else if (typeof value === "object" && value !== null) {
1142
+ // Nested group like { joint_0: 1.2, joint_1: 3.4 }
1143
+ for (const [subKey, subVal] of Object.entries(value)) {
1144
+ const fullKey = `${key} | ${subKey}`;
1145
+ if (typeof subVal === "number" && isFinite(subVal)) {
1146
+ if (!stats[fullKey]) {
1147
+ stats[fullKey] = { min: subVal, max: subVal };
1148
+ } else {
1149
+ if (subVal < stats[fullKey].min) stats[fullKey].min = subVal;
1150
+ if (subVal > stats[fullKey].max) stats[fullKey].max = subVal;
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ return Object.entries(stats).map(([column, { min, max }]) => ({
1160
+ column,
1161
+ min: Math.round(min * 1000) / 1000,
1162
+ max: Math.round(max * 1000) / 1000,
1163
+ }));
1164
+ }
1165
+
1166
+ /**
1167
+ * Load all episode lengths from the episodes metadata parquet files (v3.0).
1168
+ * Returns min/max/mean/median/std and a histogram, or null if unavailable.
1169
+ */
1170
+ export async function loadAllEpisodeLengthsV3(
1171
+ repoId: string,
1172
+ version: string,
1173
+ fps: number,
1174
+ ): Promise<EpisodeLengthStats | null> {
1175
+ try {
1176
+ const allEpisodes: { index: number; length: number }[] = [];
1177
+ let fileIndex = 0;
1178
+ const chunkIndex = 0;
1179
+
1180
+ while (true) {
1181
+ const path = `meta/episodes/chunk-${chunkIndex.toString().padStart(3, "0")}/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
1182
+ const url = buildVersionedUrl(repoId, version, path);
1183
+ try {
1184
+ const buf = await fetchParquetFile(url);
1185
+ const rows = await readParquetAsObjects(buf, []);
1186
+ if (rows.length === 0 && fileIndex > 0) break;
1187
+ for (const row of rows) {
1188
+ const parsed = parseEpisodeRowSimple(row);
1189
+ allEpisodes.push({ index: parsed.episode_index, length: parsed.length });
1190
+ }
1191
+ fileIndex++;
1192
+ } catch {
1193
+ break;
1194
+ }
1195
+ }
1196
+
1197
+ if (allEpisodes.length === 0) return null;
1198
+
1199
+ const withSeconds = allEpisodes.map((ep) => ({
1200
+ episodeIndex: ep.index,
1201
+ frames: ep.length,
1202
+ lengthSeconds: Math.round((ep.length / fps) * 100) / 100,
1203
+ }));
1204
+
1205
+ const sortedByLength = [...withSeconds].sort((a, b) => a.lengthSeconds - b.lengthSeconds);
1206
+ const shortestEpisodes = sortedByLength.slice(0, 5);
1207
+ const longestEpisodes = sortedByLength.slice(-5).reverse();
1208
+
1209
+ const lengths = withSeconds.map((e) => e.lengthSeconds);
1210
+ const sum = lengths.reduce((a, b) => a + b, 0);
1211
+ const mean = Math.round((sum / lengths.length) * 100) / 100;
1212
+
1213
+ const sorted = [...lengths].sort((a, b) => a - b);
1214
+ const mid = Math.floor(sorted.length / 2);
1215
+ const median = sorted.length % 2 === 0
1216
+ ? Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 100) / 100
1217
+ : sorted[mid];
1218
+
1219
+ const variance = lengths.reduce((acc, l) => acc + (l - mean) ** 2, 0) / lengths.length;
1220
+ const std = Math.round(Math.sqrt(variance) * 100) / 100;
1221
+
1222
+ // Build histogram
1223
+ const histMin = Math.min(...lengths);
1224
+ const histMax = Math.max(...lengths);
1225
+
1226
+ if (histMax === histMin) {
1227
+ return {
1228
+ shortestEpisodes, longestEpisodes, allEpisodeLengths: withSeconds,
1229
+ meanEpisodeLength: mean, medianEpisodeLength: median, stdEpisodeLength: std,
1230
+ episodeLengthHistogram: [{ binLabel: `${histMin.toFixed(1)}s`, count: lengths.length }],
1231
+ };
1232
+ }
1233
+
1234
+ const p1 = sorted[Math.floor(sorted.length * 0.01)];
1235
+ const p99 = sorted[Math.ceil(sorted.length * 0.99) - 1];
1236
+ const range = (p99 - p1) || 1;
1237
+
1238
+ const targetBins = Math.max(10, Math.min(50, Math.ceil(Math.log2(lengths.length) + 1)));
1239
+ const rawBinWidth = range / targetBins;
1240
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawBinWidth)));
1241
+ const niceSteps = [1, 2, 2.5, 5, 10];
1242
+ const niceBinWidth = niceSteps.map((s) => s * magnitude).find((w) => w >= rawBinWidth) ?? rawBinWidth;
1243
+
1244
+ const niceMin = Math.floor(p1 / niceBinWidth) * niceBinWidth;
1245
+ const niceMax = Math.ceil(p99 / niceBinWidth) * niceBinWidth;
1246
+ const actualBinCount = Math.max(1, Math.round((niceMax - niceMin) / niceBinWidth));
1247
+ const bins = Array.from({ length: actualBinCount }, () => 0);
1248
+
1249
+ for (const len of lengths) {
1250
+ let binIdx = Math.floor((len - niceMin) / niceBinWidth);
1251
+ if (binIdx < 0) binIdx = 0;
1252
+ if (binIdx >= actualBinCount) binIdx = actualBinCount - 1;
1253
+ bins[binIdx]++;
1254
+ }
1255
+
1256
+ const histogram = bins.map((count, i) => {
1257
+ const lo = niceMin + i * niceBinWidth;
1258
+ const hi = lo + niceBinWidth;
1259
+ return { binLabel: `${lo.toFixed(1)}–${hi.toFixed(1)}s`, count };
1260
+ });
1261
+
1262
+ return {
1263
+ shortestEpisodes, longestEpisodes, allEpisodeLengths: withSeconds,
1264
+ meanEpisodeLength: mean, medianEpisodeLength: median, stdEpisodeLength: std,
1265
+ episodeLengthHistogram: histogram,
1266
+ };
1267
+ } catch {
1268
+ return null;
1269
+ }
1270
+ }
1271
+
1272
+ /**
1273
+ * Load video frame info for all episodes across all cameras.
1274
+ * Returns camera names + a map of camera → EpisodeFrameInfo[].
1275
+ */
1276
+ export async function loadAllEpisodeFrameInfo(
1277
+ repoId: string,
1278
+ version: string,
1279
+ info: DatasetMetadata,
1280
+ ): Promise<EpisodeFramesData> {
1281
+ const videoFeatures = Object.entries(info.features).filter(([, f]) => f.dtype === "video");
1282
+ if (videoFeatures.length === 0) return { cameras: [], framesByCamera: {} };
1283
+
1284
+ const cameras = videoFeatures.map(([key]) => key);
1285
+ const framesByCamera: Record<string, EpisodeFrameInfo[]> = {};
1286
+ for (const cam of cameras) framesByCamera[cam] = [];
1287
+
1288
+ if (version === "v3.0") {
1289
+ let fileIndex = 0;
1290
+ while (true) {
1291
+ const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
1292
+ try {
1293
+ const buf = await fetchParquetFile(buildVersionedUrl(repoId, version, path));
1294
+ const rows = await readParquetAsObjects(buf, []);
1295
+ if (rows.length === 0 && fileIndex > 0) break;
1296
+ for (const row of rows) {
1297
+ const epIdx = Number(row["episode_index"] ?? 0);
1298
+ for (const cam of cameras) {
1299
+ const cIdx = Number(row[`videos/${cam}/chunk_index`] ?? row["video_chunk_index"] ?? 0);
1300
+ const fIdx = Number(row[`videos/${cam}/file_index`] ?? row["video_file_index"] ?? 0);
1301
+ const fromTs = Number(row[`videos/${cam}/from_timestamp`] ?? row["video_from_timestamp"] ?? 0);
1302
+ const toTs = Number(row[`videos/${cam}/to_timestamp`] ?? row["video_to_timestamp"] ?? 30);
1303
+ const videoPath = `videos/${cam}/chunk-${cIdx.toString().padStart(3, "0")}/file-${fIdx.toString().padStart(3, "0")}.mp4`;
1304
+ framesByCamera[cam].push({
1305
+ episodeIndex: epIdx,
1306
+ videoUrl: buildVersionedUrl(repoId, version, videoPath),
1307
+ firstFrameTime: fromTs,
1308
+ lastFrameTime: Math.max(0, toTs - 0.05),
1309
+ });
1310
+ }
1311
+ }
1312
+ fileIndex++;
1313
+ } catch {
1314
+ break;
1315
+ }
1316
+ }
1317
+ return { cameras, framesByCamera };
1318
+ }
1319
+
1320
+ // v2.x — construct URLs from template
1321
+ for (let i = 0; i < info.total_episodes; i++) {
1322
+ const chunk = Math.floor(i / (info.chunks_size || 1000));
1323
+ for (const cam of cameras) {
1324
+ const videoPath = formatStringWithVars(info.video_path, {
1325
+ video_key: cam,
1326
+ episode_chunk: chunk.toString().padStart(3, "0"),
1327
+ episode_index: i.toString().padStart(6, "0"),
1328
+ });
1329
+ framesByCamera[cam].push({
1330
+ episodeIndex: i,
1331
+ videoUrl: buildVersionedUrl(repoId, version, videoPath),
1332
+ firstFrameTime: 0,
1333
+ lastFrameTime: null,
1334
+ });
1335
+ }
1336
+ }
1337
+ return { cameras, framesByCamera };
1338
+ }
1339
+
1340
  // Safe wrapper for UI error display
1341
  export async function getEpisodeDataSafe(
1342
  org: string,
src/components/overview-panel.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useRef, useCallback } from "react";
4
+ import type { EpisodeFrameInfo, EpisodeFramesData } from "@/app/[org]/[dataset]/[episode]/fetch-data";
5
+
6
+ const PAGE_SIZE = 48;
7
+
8
+ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast: boolean }) {
9
+ const containerRef = useRef<HTMLDivElement>(null);
10
+ const videoRef = useRef<HTMLVideoElement>(null);
11
+ const [inView, setInView] = useState(false);
12
+
13
+ useEffect(() => {
14
+ const el = containerRef.current;
15
+ if (!el) return;
16
+ const obs = new IntersectionObserver(
17
+ ([e]) => { if (e.isIntersecting) { setInView(true); obs.disconnect(); } },
18
+ { rootMargin: "200px" },
19
+ );
20
+ obs.observe(el);
21
+ return () => obs.disconnect();
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ const video = videoRef.current;
26
+ if (!video || !inView) return;
27
+
28
+ const seek = () => {
29
+ if (showLast) {
30
+ video.currentTime = info.lastFrameTime ?? Math.max(0, video.duration - 0.05);
31
+ } else {
32
+ video.currentTime = info.firstFrameTime;
33
+ }
34
+ };
35
+
36
+ if (video.readyState >= 1) {
37
+ seek();
38
+ } else {
39
+ video.addEventListener("loadedmetadata", seek, { once: true });
40
+ return () => video.removeEventListener("loadedmetadata", seek);
41
+ }
42
+ }, [inView, showLast, info]);
43
+
44
+ return (
45
+ <div ref={containerRef} className="flex flex-col items-center">
46
+ <div className="w-full aspect-video bg-slate-800 rounded overflow-hidden">
47
+ {inView ? (
48
+ <video
49
+ ref={videoRef}
50
+ src={info.videoUrl}
51
+ preload="metadata"
52
+ muted
53
+ className="w-full h-full object-cover"
54
+ />
55
+ ) : (
56
+ <div className="w-full h-full animate-pulse bg-slate-700" />
57
+ )}
58
+ </div>
59
+ <p className="text-xs text-slate-400 mt-1 tabular-nums">ep {info.episodeIndex}</p>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ interface OverviewPanelProps {
65
+ data: EpisodeFramesData | null;
66
+ loading: boolean;
67
+ }
68
+
69
+ export default function OverviewPanel({ data, loading }: OverviewPanelProps) {
70
+ const [selectedCamera, setSelectedCamera] = useState<string>("");
71
+ const [showLast, setShowLast] = useState(false);
72
+ const [page, setPage] = useState(0);
73
+
74
+ // Auto-select first camera when data arrives
75
+ useEffect(() => {
76
+ if (data && data.cameras.length > 0 && !selectedCamera) {
77
+ setSelectedCamera(data.cameras[0]);
78
+ }
79
+ }, [data, selectedCamera]);
80
+
81
+ const handleCameraChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
82
+ setSelectedCamera(e.target.value);
83
+ setPage(0);
84
+ }, []);
85
+
86
+ if (loading || !data) {
87
+ return (
88
+ <div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
89
+ <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
90
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
91
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
92
+ </svg>
93
+ Loading episode frames…
94
+ </div>
95
+ );
96
+ }
97
+
98
+ const frames = data.framesByCamera[selectedCamera] ?? [];
99
+
100
+ if (frames.length === 0) {
101
+ return <p className="text-slate-500 italic py-8 text-center">No episode frames available.</p>;
102
+ }
103
+
104
+ const totalPages = Math.ceil(frames.length / PAGE_SIZE);
105
+ const pageFrames = frames.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
106
+
107
+ return (
108
+ <div className="max-w-7xl mx-auto py-6 space-y-5">
109
+ <p className="text-sm text-slate-500">
110
+ Use first/last frame views to spot episodes with bad end states, or other anomalies — and to visualize diversity across the dataset.
111
+ </p>
112
+
113
+ {/* Controls row */}
114
+ <div className="flex items-center justify-between flex-wrap gap-4">
115
+ <div className="flex items-center gap-5">
116
+ {/* Camera selector */}
117
+ {data.cameras.length > 1 && (
118
+ <select
119
+ value={selectedCamera}
120
+ onChange={handleCameraChange}
121
+ className="bg-slate-800 text-slate-200 text-sm rounded px-3 py-1.5 border border-slate-600 focus:outline-none focus:border-orange-500"
122
+ >
123
+ {data.cameras.map((cam) => (
124
+ <option key={cam} value={cam}>{cam}</option>
125
+ ))}
126
+ </select>
127
+ )}
128
+
129
+ {/* First / Last toggle */}
130
+ <div className="flex items-center gap-3">
131
+ <span className={`text-sm ${!showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}>
132
+ First Frame
133
+ </span>
134
+ <button
135
+ onClick={() => setShowLast((v) => !v)}
136
+ className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${showLast ? "bg-orange-500" : "bg-slate-600"}`}
137
+ aria-label="Toggle first/last frame"
138
+ >
139
+ <span
140
+ className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${showLast ? "translate-x-[18px]" : "translate-x-[3px]"}`}
141
+ />
142
+ </button>
143
+ <span className={`text-sm ${showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}>
144
+ Last Frame
145
+ </span>
146
+ </div>
147
+ </div>
148
+
149
+ {/* Pagination */}
150
+ {totalPages > 1 && (
151
+ <div className="flex items-center gap-2 text-sm text-slate-300">
152
+ <button
153
+ disabled={page === 0}
154
+ onClick={() => setPage((p) => p - 1)}
155
+ className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed"
156
+ >
157
+ ← Prev
158
+ </button>
159
+ <span className="tabular-nums">
160
+ {page + 1} / {totalPages}
161
+ </span>
162
+ <button
163
+ disabled={page === totalPages - 1}
164
+ onClick={() => setPage((p) => p + 1)}
165
+ className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed"
166
+ >
167
+ Next →
168
+ </button>
169
+ </div>
170
+ )}
171
+ </div>
172
+
173
+ {/* Adaptive grid — only current page's thumbnails are mounted */}
174
+ <div className="grid gap-3" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}>
175
+ {pageFrames.map((info) => (
176
+ <FrameThumbnail key={`${selectedCamera}-${info.episodeIndex}`} info={info} showLast={showLast} />
177
+ ))}
178
+ </div>
179
+ </div>
180
+ );
181
+ }
src/components/side-nav.tsx CHANGED
@@ -24,46 +24,29 @@ const Sidebar: React.FC<SidebarProps> = ({
24
  prevPage,
25
  nextPage,
26
  }) => {
27
- const [sidebarVisible, setSidebarVisible] = React.useState(true);
28
- const toggleSidebar = () => setSidebarVisible((prev) => !prev);
29
-
30
- const sidebarRef = React.useRef<HTMLDivElement>(null);
31
-
32
- React.useEffect(() => {
33
- if (!sidebarVisible) return;
34
- function handleClickOutside(event: MouseEvent) {
35
- // If click is outside the sidebar nav
36
- if (
37
- sidebarRef.current &&
38
- !sidebarRef.current.contains(event.target as Node)
39
- ) {
40
- setTimeout(() => setSidebarVisible(false), 500);
41
- }
42
- }
43
- document.addEventListener("mousedown", handleClickOutside);
44
- return () => {
45
- document.removeEventListener("mousedown", handleClickOutside);
46
- };
47
- }, [sidebarVisible]);
48
 
49
  return (
50
- <div className="flex z-10 min-h-screen absolute md:static" ref={sidebarRef}>
 
51
  <nav
52
- className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words md:max-h-screen w-60 md:shrink ${
53
- !sidebarVisible ? "hidden" : ""
54
- }`}
55
  aria-label="Sidebar navigation"
56
  >
57
- <ul>
58
- <li>Number of frames: {datasetInfo.total_frames}</li>
59
- <li>Number of episodes: {datasetInfo.total_episodes}</li>
60
- <li>Frames per second: {datasetInfo.fps}</li>
 
61
  </ul>
62
 
63
- <p>Episodes:</p>
64
 
65
- {/* episodes menu for medium & large screens */}
66
- <div className="ml-2 block">
67
  <ul>
68
  {paginatedEpisodes.map((episode) => (
69
  <li key={episode} className="mt-0.5 font-mono text-sm">
@@ -106,13 +89,14 @@ const Sidebar: React.FC<SidebarProps> = ({
106
  )}
107
  </div>
108
  </nav>
109
- {/* Toggle sidebar button */}
 
110
  <button
111
- className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0"
112
- onClick={toggleSidebar}
113
  title="Toggle sidebar"
114
  >
115
- <div className="h-10 w-2 rounded-full bg-slate-500"></div>
116
  </button>
117
  </div>
118
  );
 
24
  prevPage,
25
  nextPage,
26
  }) => {
27
+ // On mobile, allow toggling; on desktop the sidebar is always visible
28
+ const [mobileVisible, setMobileVisible] = React.useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  return (
31
+ <div className="flex z-10 shrink-0">
32
+ {/* Sidebar panel — always visible on md+, togglable on mobile */}
33
  <nav
34
+ className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words w-60 ${
35
+ mobileVisible ? "block" : "hidden"
36
+ } md:block`}
37
  aria-label="Sidebar navigation"
38
  >
39
+ {/* Basic dataset info */}
40
+ <ul className="text-sm text-slate-300 space-y-0.5">
41
+ <li>Frames: {datasetInfo.total_frames.toLocaleString()}</li>
42
+ <li>Episodes: {datasetInfo.total_episodes.toLocaleString()}</li>
43
+ <li>FPS: {datasetInfo.fps}</li>
44
  </ul>
45
 
46
+ <p className="mt-4 text-sm font-semibold text-slate-200">Episodes:</p>
47
 
48
+ {/* Episodes list */}
49
+ <div className="ml-2 mt-1">
50
  <ul>
51
  {paginatedEpisodes.map((episode) => (
52
  <li key={episode} className="mt-0.5 font-mono text-sm">
 
89
  )}
90
  </div>
91
  </nav>
92
+
93
+ {/* Mobile toggle button */}
94
  <button
95
+ className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0 md:hidden"
96
+ onClick={() => setMobileVisible((prev) => !prev)}
97
  title="Toggle sidebar"
98
  >
99
+ <div className="h-10 w-2 rounded-full bg-slate-500" />
100
  </button>
101
  </div>
102
  );
src/components/stats-panel.tsx ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useMemo, useCallback } from "react";
4
+ import type {
5
+ DatasetDisplayInfo,
6
+ ColumnMinMax,
7
+ EpisodeLengthStats,
8
+ EpisodeLengthInfo,
9
+ CameraInfo,
10
+ } from "@/app/[org]/[dataset]/[episode]/fetch-data";
11
+
12
+ interface StatsPanelProps {
13
+ datasetInfo: DatasetDisplayInfo;
14
+ episodeId: number;
15
+ columnMinMax: ColumnMinMax[] | null;
16
+ episodeLengthStats: EpisodeLengthStats | null;
17
+ loading: boolean;
18
+ }
19
+
20
+ function formatTotalTime(totalFrames: number, fps: number): string {
21
+ const totalSec = totalFrames / fps;
22
+ const hours = Math.floor(totalSec / 3600);
23
+ const minutes = Math.floor((totalSec % 3600) / 60);
24
+ if (hours > 0) return `${hours}h ${minutes}m`;
25
+ return `${minutes}m`;
26
+ }
27
+
28
+ /** SVG bar chart for the episode-length histogram */
29
+ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number }[] }> = ({ data }) => {
30
+ if (data.length === 0) return null;
31
+ const maxCount = Math.max(...data.map((d) => d.count));
32
+ if (maxCount === 0) return null;
33
+
34
+ const totalWidth = 560;
35
+ const gap = Math.max(1, Math.min(3, Math.floor(60 / data.length)));
36
+ const barWidth = Math.max(4, Math.floor((totalWidth - gap * data.length) / data.length));
37
+ const chartHeight = 150;
38
+ const labelHeight = 30;
39
+ const topPad = 16;
40
+ const svgWidth = data.length * (barWidth + gap);
41
+ const labelStep = Math.max(1, Math.ceil(data.length / 10));
42
+
43
+ return (
44
+ <div className="overflow-x-auto">
45
+ <svg width={svgWidth} height={topPad + chartHeight + labelHeight} className="block" aria-label="Episode length distribution histogram">
46
+ {data.map((bin, i) => {
47
+ const barH = Math.max(1, (bin.count / maxCount) * chartHeight);
48
+ const x = i * (barWidth + gap);
49
+ const y = topPad + chartHeight - barH;
50
+ return (
51
+ <g key={i}>
52
+ <title>{`${bin.binLabel}: ${bin.count} episode${bin.count !== 1 ? "s" : ""}`}</title>
53
+ <rect x={x} y={y} width={barWidth} height={barH} className="fill-orange-500/80 hover:fill-orange-400 transition-colors" rx={Math.min(2, barWidth / 4)} />
54
+ {bin.count > 0 && barWidth >= 8 && (
55
+ <text x={x + barWidth / 2} y={y - 3} textAnchor="middle" className="fill-slate-400" fontSize={Math.min(10, barWidth - 1)}>
56
+ {bin.count}
57
+ </text>
58
+ )}
59
+ </g>
60
+ );
61
+ })}
62
+ {data.map((bin, idx) => {
63
+ const isFirst = idx === 0;
64
+ const isLast = idx === data.length - 1;
65
+ if (!isFirst && !isLast && idx % labelStep !== 0) return null;
66
+ const label = bin.binLabel.split("–")[0];
67
+ return (
68
+ <text key={idx} x={idx * (barWidth + gap) + barWidth / 2} y={topPad + chartHeight + 14} textAnchor="middle" className="fill-slate-400" fontSize={9}>
69
+ {label}s
70
+ </text>
71
+ );
72
+ })}
73
+ </svg>
74
+ </div>
75
+ );
76
+ };
77
+
78
+ const Card: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
79
+ <div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
80
+ <p className="text-xs text-slate-400 uppercase tracking-wide">{label}</p>
81
+ <p className="text-xl font-bold tabular-nums mt-1">{value}</p>
82
+ </div>
83
+ );
84
+
85
+ function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
86
+ const globalMin = useMemo(() => Math.min(...episodes.map((e) => e.lengthSeconds)), [episodes]);
87
+ const globalMax = useMemo(() => Math.max(...episodes.map((e) => e.lengthSeconds)), [episodes]);
88
+
89
+ const [rangeMin, setRangeMin] = useState(globalMin);
90
+ const [rangeMax, setRangeMax] = useState(globalMax);
91
+ const [showOutside, setShowOutside] = useState(false);
92
+ const [copied, setCopied] = useState(false);
93
+
94
+ const filtered = useMemo(() => {
95
+ const inRange = episodes.filter((e) => e.lengthSeconds >= rangeMin && e.lengthSeconds <= rangeMax);
96
+ const outRange = episodes.filter((e) => e.lengthSeconds < rangeMin || e.lengthSeconds > rangeMax);
97
+ return showOutside ? outRange : inRange;
98
+ }, [episodes, rangeMin, rangeMax, showOutside]);
99
+
100
+ const ids = useMemo(() => filtered.map((e) => e.episodeIndex).sort((a, b) => a - b), [filtered]);
101
+
102
+ const handleCopy = useCallback(() => {
103
+ navigator.clipboard.writeText(ids.join(", "));
104
+ setCopied(true);
105
+ setTimeout(() => setCopied(false), 1500);
106
+ }, [ids]);
107
+
108
+ const step = Math.max(0.01, Math.round((globalMax - globalMin) * 0.001 * 100) / 100) || 0.01;
109
+
110
+ return (
111
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
112
+ <h3 className="text-sm font-semibold text-slate-200">Episode Length Filter</h3>
113
+
114
+ {/* Range slider row */}
115
+ <div className="space-y-2">
116
+ <div className="flex items-center justify-between text-xs text-slate-400">
117
+ <span className="tabular-nums">{rangeMin.toFixed(1)}s</span>
118
+ <span className="tabular-nums">{rangeMax.toFixed(1)}s</span>
119
+ </div>
120
+ <div className="relative h-5">
121
+ {/* track background */}
122
+ <div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
123
+ {/* active range highlight */}
124
+ <div
125
+ className="absolute top-1/2 -translate-y-1/2 h-1 rounded bg-orange-500"
126
+ style={{
127
+ left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
128
+ right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
129
+ }}
130
+ />
131
+ <input
132
+ type="range"
133
+ min={globalMin}
134
+ max={globalMax}
135
+ step={step}
136
+ value={rangeMin}
137
+ onChange={(e) => setRangeMin(Math.min(Number(e.target.value), rangeMax))}
138
+ className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-orange-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-orange-500 [&::-moz-range-thumb]:cursor-pointer"
139
+ />
140
+ <input
141
+ type="range"
142
+ min={globalMin}
143
+ max={globalMax}
144
+ step={step}
145
+ value={rangeMax}
146
+ onChange={(e) => setRangeMax(Math.max(Number(e.target.value), rangeMin))}
147
+ className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-orange-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-orange-500 [&::-moz-range-thumb]:cursor-pointer"
148
+ />
149
+ </div>
150
+ </div>
151
+
152
+ {/* Mode selector */}
153
+ <div className="flex items-center gap-3">
154
+ <span className="text-xs text-slate-400">Show:</span>
155
+ <select
156
+ value={showOutside ? "outside" : "inside"}
157
+ onChange={(e) => setShowOutside(e.target.value === "outside")}
158
+ className="bg-slate-900 text-slate-200 text-xs rounded px-2 py-1 border border-slate-600 focus:outline-none focus:border-orange-500"
159
+ >
160
+ <option value="inside">Episodes in range</option>
161
+ <option value="outside">Episodes outside range</option>
162
+ </select>
163
+ <span className="text-xs text-slate-500 tabular-nums ml-auto">{ids.length} episode{ids.length !== 1 ? "s" : ""}</span>
164
+ </div>
165
+
166
+ {/* Results box */}
167
+ <div className="relative bg-slate-900/70 rounded-md border border-slate-700 p-3 max-h-40 overflow-y-auto">
168
+ <button
169
+ onClick={handleCopy}
170
+ className="sticky top-0 float-right ml-2 p-1.5 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-400 hover:text-slate-200 transition-colors backdrop-blur-sm"
171
+ title="Copy to clipboard"
172
+ >
173
+ {copied ? (
174
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-400"><polyline points="20 6 9 17 4 12" /></svg>
175
+ ) : (
176
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
177
+ )}
178
+ </button>
179
+ <p className="text-sm text-slate-300 tabular-nums leading-relaxed">
180
+ {ids.length > 0 ? ids.join(", ") : <span className="text-slate-500 italic">No episodes match</span>}
181
+ </p>
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ const StatsPanel: React.FC<StatsPanelProps> = ({
188
+ datasetInfo,
189
+ episodeId,
190
+ columnMinMax,
191
+ episodeLengthStats,
192
+ loading,
193
+ }) => {
194
+ const els = episodeLengthStats;
195
+
196
+ return (
197
+ <div className="max-w-4xl mx-auto py-6 space-y-8">
198
+ <div>
199
+ <h2 className="text-xl text-slate-100"><span className="font-bold">Dataset Statistics:</span> <span className="font-normal text-slate-400">{datasetInfo.repoId}</span></h2>
200
+ </div>
201
+
202
+ {/* Overview cards */}
203
+ <div className="grid grid-cols-3 gap-4">
204
+ <Card label="Robot Type" value={datasetInfo.robot_type ?? "unknown"} />
205
+ <Card label="Dataset Version" value={datasetInfo.codebase_version} />
206
+ <Card label="Tasks" value={datasetInfo.total_tasks} />
207
+ </div>
208
+
209
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
210
+ <Card label="Total Frames" value={datasetInfo.total_frames.toLocaleString()} />
211
+ <Card label="Total Episodes" value={datasetInfo.total_episodes.toLocaleString()} />
212
+ <Card label="FPS" value={datasetInfo.fps} />
213
+ <Card label="Total Recording Time" value={formatTotalTime(datasetInfo.total_frames, datasetInfo.fps)} />
214
+ </div>
215
+
216
+ {/* Camera resolutions */}
217
+ {datasetInfo.cameras.length > 0 && (
218
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
219
+ <h3 className="text-sm font-semibold text-slate-200 mb-3">Camera Resolutions</h3>
220
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
221
+ {datasetInfo.cameras.map((cam: CameraInfo) => (
222
+ <div key={cam.name} className="bg-slate-900/50 rounded-md p-3">
223
+ <p className="text-xs text-slate-400 mb-1 truncate" title={cam.name}>{cam.name}</p>
224
+ <p className="text-base font-bold tabular-nums">{cam.width}×{cam.height}</p>
225
+ </div>
226
+ ))}
227
+ </div>
228
+ </div>
229
+ )}
230
+
231
+ {/* Loading spinner for async stats */}
232
+ {loading && (
233
+ <div className="flex items-center gap-2 text-slate-400 text-sm py-4">
234
+ <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
235
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
236
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
237
+ </svg>
238
+ Computing episode statistics…
239
+ </div>
240
+ )}
241
+
242
+ {/* Episode length section */}
243
+ {els && (
244
+ <>
245
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
246
+ <h3 className="text-sm font-semibold text-slate-200 mb-4">Episode Lengths</h3>
247
+ <div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
248
+ <Card label="Shortest" value={`${els.shortestEpisodes[0]?.lengthSeconds ?? "–"}s`} />
249
+ <Card label="Longest" value={`${els.longestEpisodes[els.longestEpisodes.length - 1]?.lengthSeconds ?? "–"}s`} />
250
+ <Card label="Mean" value={`${els.meanEpisodeLength}s`} />
251
+ <Card label="Median" value={`${els.medianEpisodeLength}s`} />
252
+ <Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
253
+ </div>
254
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
255
+ <div>
256
+ <p className="text-xs text-slate-400 uppercase tracking-wide mb-2">Top 5 Shortest</p>
257
+ <table className="w-full text-sm">
258
+ <tbody>
259
+ {els.shortestEpisodes.map((ep) => (
260
+ <tr key={ep.episodeIndex} className="border-b border-slate-800/60">
261
+ <td className="py-1 text-slate-300">ep {ep.episodeIndex}</td>
262
+ <td className="py-1 text-right tabular-nums font-semibold">{ep.lengthSeconds}s</td>
263
+ <td className="py-1 text-right tabular-nums text-slate-500 text-xs">{ep.frames} fr</td>
264
+ </tr>
265
+ ))}
266
+ </tbody>
267
+ </table>
268
+ </div>
269
+ <div>
270
+ <p className="text-xs text-slate-400 uppercase tracking-wide mb-2">Top 5 Longest</p>
271
+ <table className="w-full text-sm">
272
+ <tbody>
273
+ {els.longestEpisodes.map((ep) => (
274
+ <tr key={ep.episodeIndex} className="border-b border-slate-800/60">
275
+ <td className="py-1 text-slate-300">ep {ep.episodeIndex}</td>
276
+ <td className="py-1 text-right tabular-nums font-semibold">{ep.lengthSeconds}s</td>
277
+ <td className="py-1 text-right tabular-nums text-slate-500 text-xs">{ep.frames} fr</td>
278
+ </tr>
279
+ ))}
280
+ </tbody>
281
+ </table>
282
+ </div>
283
+ </div>
284
+ </div>
285
+
286
+ {els.episodeLengthHistogram.length > 0 && (
287
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
288
+ <h3 className="text-sm font-semibold text-slate-200 mb-4">
289
+ Episode Length Distribution
290
+ <span className="text-xs text-slate-500 ml-2 font-normal">
291
+ {els.episodeLengthHistogram.length} bin{els.episodeLengthHistogram.length !== 1 ? "s" : ""}
292
+ </span>
293
+ </h3>
294
+ <EpisodeLengthHistogram data={els.episodeLengthHistogram} />
295
+ </div>
296
+ )}
297
+
298
+ <EpisodeLengthFilter episodes={els.allEpisodeLengths} />
299
+ </>
300
+ )}
301
+
302
+ {/* Column min/max table */}
303
+ {columnMinMax && columnMinMax.length > 0 && (
304
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
305
+ <h3 className="text-sm font-semibold text-slate-200 mb-4">
306
+ Column Min / Max
307
+ <span className="text-xs text-slate-500 ml-2 font-normal">(episode {episodeId})</span>
308
+ </h3>
309
+ <div className="overflow-x-auto max-h-96 overflow-y-auto">
310
+ <table className="w-full text-sm">
311
+ <thead>
312
+ <tr className="text-slate-400 border-b border-slate-700 sticky top-0 bg-slate-800">
313
+ <th className="text-left py-2 pr-4 font-medium">Column</th>
314
+ <th className="text-right py-2 px-4 font-medium">Min</th>
315
+ <th className="text-right py-2 pl-4 font-medium">Max</th>
316
+ </tr>
317
+ </thead>
318
+ <tbody>
319
+ {columnMinMax.map((col) => (
320
+ <tr key={col.column} className="border-b border-slate-800/60 hover:bg-slate-700/20">
321
+ <td className="py-1.5 pr-4 text-slate-300 truncate max-w-xs" title={col.column}>{col.column}</td>
322
+ <td className="py-1.5 px-4 text-right tabular-nums text-slate-300">{col.min}</td>
323
+ <td className="py-1.5 pl-4 text-right tabular-nums text-slate-300">{col.max}</td>
324
+ </tr>
325
+ ))}
326
+ </tbody>
327
+ </table>
328
+ </div>
329
+ </div>
330
+ )}
331
+ </div>
332
+ );
333
+ };
334
+
335
+ export default StatsPanel;
src/components/urdf-viewer.tsx ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useRef, useMemo, useCallback, Suspense } from "react";
4
+ import { Canvas, useFrame, useLoader } from "@react-three/fiber";
5
+ import { OrbitControls, Grid, Environment } from "@react-three/drei";
6
+ import * as THREE from "three";
7
+ import { STLLoader } from "three/addons/loaders/STLLoader.js";
8
+ import {
9
+ SO101_JOINTS,
10
+ SO101_LINKS,
11
+ MATERIAL_COLORS,
12
+ autoMatchJoints,
13
+ type JointDef,
14
+ type MeshDef,
15
+ } from "@/lib/so101-robot";
16
+ import type { EpisodeData } from "@/app/[org]/[dataset]/[episode]/fetch-data";
17
+
18
+ const SERIES_DELIM = " | ";
19
+
20
+ // ─── STL Mesh component ───
21
+ function STLMesh({ mesh }: { mesh: MeshDef }) {
22
+ const geometry = useLoader(STLLoader, mesh.file);
23
+ const color = MATERIAL_COLORS[mesh.material];
24
+ return (
25
+ <mesh
26
+ geometry={geometry}
27
+ position={mesh.origin.xyz}
28
+ rotation={new THREE.Euler(...mesh.origin.rpy, "XYZ")}
29
+ >
30
+ <meshStandardMaterial
31
+ color={color}
32
+ metalness={mesh.material === "motor" ? 0.7 : 0.1}
33
+ roughness={mesh.material === "motor" ? 0.3 : 0.6}
34
+ />
35
+ </mesh>
36
+ );
37
+ }
38
+
39
+ // ─── Link visual: renders all meshes for a link ───
40
+ function LinkVisual({ linkIndex }: { linkIndex: number }) {
41
+ const link = SO101_LINKS[linkIndex];
42
+ if (!link) return null;
43
+ return (
44
+ <>
45
+ {link.meshes.map((mesh, i) => (
46
+ <STLMesh key={i} mesh={mesh} />
47
+ ))}
48
+ </>
49
+ );
50
+ }
51
+
52
+ // ─── Joint group: applies origin transform + joint rotation ───
53
+ function JointGroup({
54
+ joint,
55
+ angle,
56
+ linkIndex,
57
+ children,
58
+ }: {
59
+ joint: JointDef;
60
+ angle: number;
61
+ linkIndex: number;
62
+ children?: React.ReactNode;
63
+ }) {
64
+ const rotRef = useRef<THREE.Group>(null);
65
+
66
+ useEffect(() => {
67
+ if (rotRef.current) {
68
+ rotRef.current.quaternion.setFromAxisAngle(new THREE.Vector3(...joint.axis), angle);
69
+ }
70
+ }, [angle, joint.axis]);
71
+
72
+ return (
73
+ <group position={joint.origin.xyz} rotation={new THREE.Euler(...joint.origin.rpy, "XYZ")}>
74
+ <group ref={rotRef}>
75
+ <LinkVisual linkIndex={linkIndex} />
76
+ {children}
77
+ </group>
78
+ </group>
79
+ );
80
+ }
81
+
82
+ // ─── Full robot arm ───
83
+ function RobotArm({ angles }: { angles: Record<string, number> }) {
84
+ return (
85
+ <group>
86
+ {/* Base link (no parent joint) */}
87
+ <LinkVisual linkIndex={0} />
88
+
89
+ {/* shoulder_pan → shoulder_link (1) */}
90
+ <JointGroup joint={SO101_JOINTS[0]} angle={angles.shoulder_pan ?? 0} linkIndex={1}>
91
+ {/* shoulder_lift → upper_arm_link (2) */}
92
+ <JointGroup joint={SO101_JOINTS[1]} angle={angles.shoulder_lift ?? 0} linkIndex={2}>
93
+ {/* elbow_flex → lower_arm_link (3) */}
94
+ <JointGroup joint={SO101_JOINTS[2]} angle={angles.elbow_flex ?? 0} linkIndex={3}>
95
+ {/* wrist_flex → wrist_link (4) */}
96
+ <JointGroup joint={SO101_JOINTS[3]} angle={angles.wrist_flex ?? 0} linkIndex={4}>
97
+ {/* wrist_roll → gripper_link (5) */}
98
+ <JointGroup joint={SO101_JOINTS[4]} angle={angles.wrist_roll ?? 0} linkIndex={5}>
99
+ {/* gripper → moving_jaw (6) */}
100
+ <JointGroup joint={SO101_JOINTS[5]} angle={angles.gripper ?? 0} linkIndex={6} />
101
+ </JointGroup>
102
+ </JointGroup>
103
+ </JointGroup>
104
+ </JointGroup>
105
+ </JointGroup>
106
+ </group>
107
+ );
108
+ }
109
+
110
+ // ─── Playback driver (advances frame inside Canvas render loop) ───
111
+ function PlaybackDriver({
112
+ playing,
113
+ fps,
114
+ totalFrames,
115
+ frameRef,
116
+ }: {
117
+ playing: boolean;
118
+ fps: number;
119
+ totalFrames: number;
120
+ frameRef: React.MutableRefObject<number>;
121
+ }) {
122
+ const elapsed = useRef(0);
123
+ useFrame((_, delta) => {
124
+ if (!playing) {
125
+ elapsed.current = 0;
126
+ return;
127
+ }
128
+ elapsed.current += delta;
129
+ const frameDelta = Math.floor(elapsed.current * fps);
130
+ if (frameDelta > 0) {
131
+ elapsed.current -= frameDelta / fps;
132
+ frameRef.current = (frameRef.current + frameDelta) % totalFrames;
133
+ }
134
+ });
135
+ return null;
136
+ }
137
+
138
+ // ─── Detect raw servo values (0-4096) vs radians ───
139
+ function detectAndConvert(values: number[]): number[] {
140
+ if (values.length === 0) return values;
141
+ const max = Math.max(...values.map(Math.abs));
142
+ if (max > 10) return values.map((v) => ((v - 2048) / 2048) * Math.PI);
143
+ return values;
144
+ }
145
+
146
+ // ─── Group columns by feature prefix ───
147
+ function groupColumnsByPrefix(keys: string[]): Record<string, string[]> {
148
+ const groups: Record<string, string[]> = {};
149
+ for (const key of keys) {
150
+ if (key === "timestamp") continue;
151
+ const parts = key.split(SERIES_DELIM);
152
+ const prefix = parts.length > 1 ? parts[0].trim() : "other";
153
+ if (!groups[prefix]) groups[prefix] = [];
154
+ groups[prefix].push(key);
155
+ }
156
+ return groups;
157
+ }
158
+
159
+ // ═══════════════════════════════════════
160
+ // ─── Main URDF Viewer ───
161
+ // ═══════════���═══════════════════════════
162
+ export default function URDFViewer({ data }: { data: EpisodeData }) {
163
+ const { flatChartData, datasetInfo } = data;
164
+ const totalFrames = flatChartData.length;
165
+ const fps = datasetInfo.fps || 30;
166
+
167
+ const columnGroups = useMemo(() => {
168
+ if (totalFrames === 0) return {};
169
+ return groupColumnsByPrefix(Object.keys(flatChartData[0]));
170
+ }, [flatChartData, totalFrames]);
171
+
172
+ const groupNames = useMemo(() => Object.keys(columnGroups), [columnGroups]);
173
+
174
+ const defaultGroup = useMemo(
175
+ () =>
176
+ groupNames.find((g) => g.toLowerCase().includes("state")) ??
177
+ groupNames.find((g) => g.toLowerCase().includes("action")) ??
178
+ groupNames[0] ?? "",
179
+ [groupNames],
180
+ );
181
+
182
+ const [selectedGroup, setSelectedGroup] = useState(defaultGroup);
183
+ useEffect(() => setSelectedGroup(defaultGroup), [defaultGroup]);
184
+
185
+ const selectedColumns = columnGroups[selectedGroup] ?? [];
186
+ const autoMapping = useMemo(() => autoMatchJoints(selectedColumns), [selectedColumns]);
187
+ const [mapping, setMapping] = useState<Record<string, string>>(autoMapping);
188
+ useEffect(() => setMapping(autoMapping), [autoMapping]);
189
+
190
+ const [frame, setFrame] = useState(0);
191
+ const [playing, setPlaying] = useState(false);
192
+ const frameRef = useRef(0);
193
+
194
+ useEffect(() => {
195
+ if (!playing) return;
196
+ const interval = setInterval(() => setFrame(frameRef.current), 33);
197
+ return () => clearInterval(interval);
198
+ }, [playing]);
199
+
200
+ const handleFrameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
201
+ const f = parseInt(e.target.value);
202
+ setFrame(f);
203
+ frameRef.current = f;
204
+ }, []);
205
+
206
+ const jointAngles = useMemo(() => {
207
+ if (totalFrames === 0) return {};
208
+ const row = flatChartData[Math.min(frame, totalFrames - 1)];
209
+ const rawValues: number[] = [];
210
+ const jointNames: string[] = [];
211
+
212
+ for (const joint of SO101_JOINTS) {
213
+ const col = mapping[joint.name];
214
+ if (col && typeof row[col] === "number") {
215
+ rawValues.push(row[col]);
216
+ jointNames.push(joint.name);
217
+ }
218
+ }
219
+
220
+ const converted = detectAndConvert(rawValues);
221
+ const angles: Record<string, number> = {};
222
+ jointNames.forEach((name, i) => {
223
+ angles[name] = converted[i];
224
+ });
225
+ return angles;
226
+ }, [flatChartData, frame, mapping, totalFrames]);
227
+
228
+ const currentTime = totalFrames > 0 ? (frame / fps).toFixed(2) : "0.00";
229
+ const totalTime = (totalFrames / fps).toFixed(2);
230
+
231
+ if (totalFrames === 0) {
232
+ return <div className="text-slate-400 p-8 text-center">No trajectory data available for this episode.</div>;
233
+ }
234
+
235
+ return (
236
+ <div className="flex-1 flex flex-col overflow-hidden">
237
+ {/* 3D Viewport */}
238
+ <div className="flex-1 min-h-0 bg-slate-950 rounded-lg overflow-hidden border border-slate-700">
239
+ <Canvas camera={{ position: [0.35, 0.25, 0.3], fov: 45, near: 0.001, far: 10 }}>
240
+ <ambientLight intensity={0.5} />
241
+ <directionalLight position={[3, 5, 4]} intensity={1.2} castShadow />
242
+ <directionalLight position={[-2, 3, -2]} intensity={0.4} />
243
+ <hemisphereLight args={["#b1e1ff", "#444444", 0.4]} />
244
+ <Suspense fallback={null}>
245
+ <RobotArm angles={jointAngles} />
246
+ </Suspense>
247
+ <Grid
248
+ args={[1, 1]}
249
+ cellSize={0.02}
250
+ cellThickness={0.5}
251
+ cellColor="#334155"
252
+ sectionSize={0.1}
253
+ sectionThickness={1}
254
+ sectionColor="#475569"
255
+ fadeDistance={1}
256
+ position={[0, 0, 0]}
257
+ />
258
+ <OrbitControls target={[0, 0.1, 0]} />
259
+ <PlaybackDriver playing={playing} fps={fps} totalFrames={totalFrames} frameRef={frameRef} />
260
+ </Canvas>
261
+ </div>
262
+
263
+ {/* Controls Panel */}
264
+ <div className="bg-slate-800/90 border-t border-slate-700 p-3 space-y-3 shrink-0">
265
+ {/* Playback bar */}
266
+ <div className="flex items-center gap-3">
267
+ <button
268
+ onClick={() => {
269
+ setPlaying(!playing);
270
+ if (!playing) frameRef.current = frame;
271
+ }}
272
+ className="w-8 h-8 flex items-center justify-center rounded bg-orange-600 hover:bg-orange-500 text-white transition-colors shrink-0"
273
+ >
274
+ {playing ? (
275
+ <svg width="12" height="14" viewBox="0 0 12 14">
276
+ <rect x="1" y="1" width="3" height="12" fill="white" />
277
+ <rect x="8" y="1" width="3" height="12" fill="white" />
278
+ </svg>
279
+ ) : (
280
+ <svg width="12" height="14" viewBox="0 0 12 14">
281
+ <polygon points="2,1 11,7 2,13" fill="white" />
282
+ </svg>
283
+ )}
284
+ </button>
285
+ <input
286
+ type="range"
287
+ min={0}
288
+ max={Math.max(totalFrames - 1, 0)}
289
+ value={frame}
290
+ onChange={handleFrameChange}
291
+ className="flex-1 h-1.5 accent-orange-500 cursor-pointer"
292
+ />
293
+ <span className="text-xs text-slate-400 tabular-nums w-28 text-right shrink-0">
294
+ {currentTime}s / {totalTime}s
295
+ </span>
296
+ <span className="text-xs text-slate-500 tabular-nums w-20 text-right shrink-0">
297
+ F {frame}/{totalFrames - 1}
298
+ </span>
299
+ </div>
300
+
301
+ {/* Data source + joint mapping */}
302
+ <div className="flex gap-4 items-start">
303
+ <div className="space-y-1 shrink-0">
304
+ <label className="text-xs text-slate-400">Data source</label>
305
+ <div className="flex gap-1 flex-wrap">
306
+ {groupNames.map((name) => (
307
+ <button
308
+ key={name}
309
+ onClick={() => setSelectedGroup(name)}
310
+ className={`px-2 py-1 text-xs rounded transition-colors ${
311
+ selectedGroup === name
312
+ ? "bg-orange-600 text-white"
313
+ : "bg-slate-700 text-slate-300 hover:bg-slate-600"
314
+ }`}
315
+ >
316
+ {name}
317
+ </button>
318
+ ))}
319
+ </div>
320
+ </div>
321
+
322
+ <div className="flex-1 overflow-x-auto">
323
+ <table className="w-full text-xs">
324
+ <thead>
325
+ <tr className="text-slate-500">
326
+ <th className="text-left font-normal px-1">URDF Joint</th>
327
+ <th className="text-left font-normal px-1">→</th>
328
+ <th className="text-left font-normal px-1">Dataset Column</th>
329
+ <th className="text-right font-normal px-1">Value (rad)</th>
330
+ </tr>
331
+ </thead>
332
+ <tbody>
333
+ {SO101_JOINTS.map((joint) => (
334
+ <tr key={joint.name} className="border-t border-slate-700/50">
335
+ <td className="px-1 py-0.5 text-slate-300 font-mono">{joint.name}</td>
336
+ <td className="px-1 text-slate-600">→</td>
337
+ <td className="px-1 py-0.5">
338
+ <select
339
+ value={mapping[joint.name] ?? ""}
340
+ onChange={(e) => setMapping((m) => ({ ...m, [joint.name]: e.target.value }))}
341
+ className="bg-slate-900 text-slate-200 text-xs rounded px-1 py-0.5 border border-slate-600 w-full max-w-[200px]"
342
+ >
343
+ <option value="">-- unmapped --</option>
344
+ {selectedColumns.map((col) => {
345
+ const label = col.split(SERIES_DELIM).pop() ?? col;
346
+ return (
347
+ <option key={col} value={col}>
348
+ {label}
349
+ </option>
350
+ );
351
+ })}
352
+ </select>
353
+ </td>
354
+ <td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
355
+ {jointAngles[joint.name] !== undefined ? jointAngles[joint.name].toFixed(3) : "—"}
356
+ </td>
357
+ </tr>
358
+ ))}
359
+ </tbody>
360
+ </table>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ );
366
+ }
src/lib/so101-robot.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type JointDef = {
2
+ name: string;
3
+ origin: { xyz: [number, number, number]; rpy: [number, number, number] };
4
+ axis: [number, number, number];
5
+ limits: [number, number];
6
+ };
7
+
8
+ export type MeshDef = {
9
+ file: string;
10
+ origin: { xyz: [number, number, number]; rpy: [number, number, number] };
11
+ material: "3d_printed" | "motor";
12
+ };
13
+
14
+ export type LinkDef = {
15
+ name: string;
16
+ meshes: MeshDef[];
17
+ };
18
+
19
+ const ASSET_BASE = "/urdf/so101/assets";
20
+ const P = Math.PI;
21
+
22
+ // ─── Visual meshes per link (from URDF) ───
23
+ export const SO101_LINKS: LinkDef[] = [
24
+ {
25
+ name: "base_link",
26
+ meshes: [
27
+ { file: `${ASSET_BASE}/base_motor_holder_so101_v1.stl`, origin: { xyz: [-0.00636471, -9.94414e-05, -0.0024], rpy: [P / 2, 0, P / 2] }, material: "3d_printed" },
28
+ { file: `${ASSET_BASE}/base_so101_v2.stl`, origin: { xyz: [-0.00636471, 0, -0.0024], rpy: [P / 2, 0, P / 2] }, material: "3d_printed" },
29
+ { file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [0.0263353, 0, 0.0437], rpy: [0, 0, 0] }, material: "motor" },
30
+ { file: `${ASSET_BASE}/waveshare_mounting_plate_so101_v2.stl`, origin: { xyz: [-0.0309827, -0.000199441, 0.0474], rpy: [P / 2, 0, P / 2] }, material: "3d_printed" },
31
+ ],
32
+ },
33
+ {
34
+ name: "shoulder_link",
35
+ meshes: [
36
+ { file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [-0.0303992, 0.000422241, -0.0417], rpy: [P / 2, P / 2, 0] }, material: "motor" },
37
+ { file: `${ASSET_BASE}/motor_holder_so101_base_v1.stl`, origin: { xyz: [-0.0675992, -0.000177759, 0.0158499], rpy: [P / 2, -P / 2, 0] }, material: "3d_printed" },
38
+ { file: `${ASSET_BASE}/rotation_pitch_so101_v1.stl`, origin: { xyz: [0.0122008, 2.22413e-05, 0.0464], rpy: [-P / 2, 0, 0] }, material: "3d_printed" },
39
+ ],
40
+ },
41
+ {
42
+ name: "upper_arm_link",
43
+ meshes: [
44
+ { file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [-0.11257, -0.0155, 0.0187], rpy: [-P, 0, -P / 2] }, material: "motor" },
45
+ { file: `${ASSET_BASE}/upper_arm_so101_v1.stl`, origin: { xyz: [-0.065085, 0.012, 0.0182], rpy: [P, 0, 0] }, material: "3d_printed" },
46
+ ],
47
+ },
48
+ {
49
+ name: "lower_arm_link",
50
+ meshes: [
51
+ { file: `${ASSET_BASE}/under_arm_so101_v1.stl`, origin: { xyz: [-0.0648499, -0.032, 0.0182], rpy: [P, 0, 0] }, material: "3d_printed" },
52
+ { file: `${ASSET_BASE}/motor_holder_so101_wrist_v1.stl`, origin: { xyz: [-0.0648499, -0.032, 0.018], rpy: [-P, 0, 0] }, material: "3d_printed" },
53
+ { file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [-0.1224, 0.0052, 0.0187], rpy: [-P, 0, -P] }, material: "motor" },
54
+ ],
55
+ },
56
+ {
57
+ name: "wrist_link",
58
+ meshes: [
59
+ { file: `${ASSET_BASE}/sts3215_03a_no_horn_v1.stl`, origin: { xyz: [0, -0.0424, 0.0306], rpy: [P / 2, P / 2, 0] }, material: "motor" },
60
+ { file: `${ASSET_BASE}/wrist_roll_pitch_so101_v2.stl`, origin: { xyz: [0, -0.028, 0.0181], rpy: [-P / 2, -P / 2, 0] }, material: "3d_printed" },
61
+ ],
62
+ },
63
+ {
64
+ name: "gripper_link",
65
+ meshes: [
66
+ { file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [0.0077, 0.0001, -0.0234], rpy: [-P / 2, 0, 0] }, material: "motor" },
67
+ { file: `${ASSET_BASE}/wrist_roll_follower_so101_v1.stl`, origin: { xyz: [0, -0.000218214, 0.000949706], rpy: [-P, 0, 0] }, material: "3d_printed" },
68
+ ],
69
+ },
70
+ {
71
+ name: "moving_jaw_link",
72
+ meshes: [
73
+ { file: `${ASSET_BASE}/moving_jaw_so101_v1.stl`, origin: { xyz: [0, 0, 0.0189], rpy: [0, 0, 0] }, material: "3d_printed" },
74
+ ],
75
+ },
76
+ ];
77
+
78
+ // Kinematic chain: each joint connects a parent link to a child link
79
+ // Index in SO101_LINKS: base=0, shoulder=1, upper_arm=2, lower_arm=3, wrist=4, gripper=5, jaw=6
80
+ export const SO101_JOINTS: JointDef[] = [
81
+ {
82
+ name: "shoulder_pan",
83
+ origin: { xyz: [0.0388353, -8.97657e-09, 0.0624], rpy: [P, 4.18253e-17, -P] },
84
+ axis: [0, 0, 1],
85
+ limits: [-1.91986, 1.91986],
86
+ },
87
+ {
88
+ name: "shoulder_lift",
89
+ origin: { xyz: [-0.0303992, -0.0182778, -0.0542], rpy: [-P / 2, -P / 2, 0] },
90
+ axis: [0, 0, 1],
91
+ limits: [-1.74533, 1.74533],
92
+ },
93
+ {
94
+ name: "elbow_flex",
95
+ origin: { xyz: [-0.11257, -0.028, 1.73763e-16], rpy: [0, 0, P / 2] },
96
+ axis: [0, 0, 1],
97
+ limits: [-1.69, 1.69],
98
+ },
99
+ {
100
+ name: "wrist_flex",
101
+ origin: { xyz: [-0.1349, 0.0052, 0], rpy: [0, 0, -P / 2] },
102
+ axis: [0, 0, 1],
103
+ limits: [-1.65806, 1.65806],
104
+ },
105
+ {
106
+ name: "wrist_roll",
107
+ origin: { xyz: [0, -0.0611, 0.0181], rpy: [P / 2, 0.0486795, P] },
108
+ axis: [0, 0, 1],
109
+ limits: [-2.74385, 2.84121],
110
+ },
111
+ {
112
+ name: "gripper",
113
+ origin: { xyz: [0.0202, 0.0188, -0.0234], rpy: [P / 2, 0, 0] },
114
+ axis: [0, 0, 1],
115
+ limits: [-0.174533, 1.74533],
116
+ },
117
+ ];
118
+
119
+ export const MATERIAL_COLORS = {
120
+ "3d_printed": "#FFD700",
121
+ motor: "#1a1a1a",
122
+ } as const;
123
+
124
+ export function isSO101Robot(robotType: string | null): boolean {
125
+ if (!robotType) return false;
126
+ const lower = robotType.toLowerCase();
127
+ return lower.includes("so100") || lower.includes("so101") || lower === "so_follower";
128
+ }
129
+
130
+ // Collect all unique STL file paths for preloading
131
+ export function getAllSTLPaths(): string[] {
132
+ const paths = new Set<string>();
133
+ for (const link of SO101_LINKS) {
134
+ for (const mesh of link.meshes) {
135
+ paths.add(mesh.file);
136
+ }
137
+ }
138
+ return [...paths];
139
+ }
140
+
141
+ // Auto-match dataset columns to URDF joint names
142
+ export function autoMatchJoints(columnKeys: string[]): Record<string, string> {
143
+ const mapping: Record<string, string> = {};
144
+ for (const joint of SO101_JOINTS) {
145
+ const exactMatch = columnKeys.find((k) => {
146
+ const suffix = k.split(" | ").pop()?.trim() ?? k;
147
+ return suffix === joint.name;
148
+ });
149
+ if (exactMatch) { mapping[joint.name] = exactMatch; continue; }
150
+ const fuzzy = columnKeys.find((k) => k.toLowerCase().includes(joint.name));
151
+ if (fuzzy) mapping[joint.name] = fuzzy;
152
+ }
153
+ return mapping;
154
+ }