mishig HF Staff Claude Sonnet 4.6 commited on
Commit
291477f
·
1 Parent(s): 15a2c53

style: run prettier formatting

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

src/app/[org]/[dataset]/[episode]/actions.ts CHANGED
@@ -28,7 +28,11 @@ export async function fetchEpisodeFrames(
28
  ): Promise<EpisodeFramesData> {
29
  const repoId = `${org}/${dataset}`;
30
  const { version, info } = await getDatasetVersionAndInfo(repoId);
31
- return loadAllEpisodeFrameInfo(repoId, version, info as unknown as DatasetMetadata);
 
 
 
 
32
  }
33
 
34
  export async function fetchCrossEpisodeVariance(
@@ -37,7 +41,12 @@ export async function fetchCrossEpisodeVariance(
37
  ): Promise<CrossEpisodeVarianceData | null> {
38
  const repoId = `${org}/${dataset}`;
39
  const { version, info } = await getDatasetVersionAndInfo(repoId);
40
- return loadCrossEpisodeActionVariance(repoId, version, info as unknown as DatasetMetadata, info.fps);
 
 
 
 
 
41
  }
42
 
43
  export async function fetchEpisodeChartData(
@@ -47,6 +56,10 @@ export async function fetchEpisodeChartData(
47
  ): Promise<Record<string, number>[]> {
48
  const repoId = `${org}/${dataset}`;
49
  const { version, info } = await getDatasetVersionAndInfo(repoId);
50
- return loadEpisodeFlatChartData(repoId, version, info as unknown as DatasetMetadata, episodeId);
 
 
 
 
 
51
  }
52
-
 
28
  ): Promise<EpisodeFramesData> {
29
  const repoId = `${org}/${dataset}`;
30
  const { version, info } = await getDatasetVersionAndInfo(repoId);
31
+ return loadAllEpisodeFrameInfo(
32
+ repoId,
33
+ version,
34
+ info as unknown as DatasetMetadata,
35
+ );
36
  }
37
 
38
  export async function fetchCrossEpisodeVariance(
 
41
  ): Promise<CrossEpisodeVarianceData | null> {
42
  const repoId = `${org}/${dataset}`;
43
  const { version, info } = await getDatasetVersionAndInfo(repoId);
44
+ return loadCrossEpisodeActionVariance(
45
+ repoId,
46
+ version,
47
+ info as unknown as DatasetMetadata,
48
+ info.fps,
49
+ );
50
  }
51
 
52
  export async function fetchEpisodeChartData(
 
56
  ): Promise<Record<string, number>[]> {
57
  const repoId = `${org}/${dataset}`;
58
  const { version, info } = await getDatasetVersionAndInfo(repoId);
59
+ return loadEpisodeFlatChartData(
60
+ repoId,
61
+ version,
62
+ info as unknown as DatasetMetadata,
63
+ episodeId,
64
+ );
65
  }
 
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx CHANGED
@@ -22,13 +22,25 @@ import {
22
  type EpisodeFramesData,
23
  type CrossEpisodeVarianceData,
24
  } from "./fetch-data";
25
- import { fetchEpisodeLengthStats, fetchEpisodeFrames, fetchCrossEpisodeVariance } from "./actions";
 
 
 
 
26
 
27
  const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
28
- const ActionInsightsPanel = lazy(() => import("@/components/action-insights-panel"));
 
 
29
  const FilteringPanel = lazy(() => import("@/components/filtering-panel"));
30
 
31
- type ActiveTab = "episodes" | "statistics" | "frames" | "insights" | "filtering" | "urdf";
 
 
 
 
 
 
32
 
33
  export default function EpisodeViewer({
34
  data,
@@ -65,7 +77,15 @@ export default function EpisodeViewer({
65
  );
66
  }
67
 
68
- function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: string; dataset?: string }) {
 
 
 
 
 
 
 
 
69
  const {
70
  datasetInfo,
71
  episodeId,
@@ -82,7 +102,9 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
82
  const loadStartRef = useRef(performance.now());
83
  useEffect(() => {
84
  if (!isLoading) {
85
- console.log(`[perf] Loading complete in ${(performance.now() - loadStartRef.current).toFixed(0)}ms (videos: ${videosReady ? '✓' : '…'}, charts: ${chartsReady ? '✓' : '…'})`);
 
 
86
  }
87
  }, [isLoading]);
88
 
@@ -93,35 +115,56 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
93
  const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
94
  if (typeof window !== "undefined") {
95
  const stored = sessionStorage.getItem("activeTab");
96
- if (stored && ["episodes", "statistics", "frames", "insights", "filtering", "urdf"].includes(stored)) {
 
 
 
 
 
 
 
 
 
 
97
  return stored as ActiveTab;
98
  }
99
  }
100
  return "episodes";
101
  });
102
  const [, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
103
- const [episodeLengthStats, setEpisodeLengthStats] = useState<EpisodeLengthStats | null>(null);
 
104
  const [statsLoading, setStatsLoading] = useState(false);
105
  const statsLoadedRef = useRef(false);
106
- const [episodeFramesData, setEpisodeFramesData] = useState<EpisodeFramesData | null>(null);
 
107
  const [framesLoading, setFramesLoading] = useState(false);
108
  const framesLoadedRef = useRef(false);
109
  const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(() => {
110
- if (typeof window !== "undefined") return sessionStorage.getItem("framesFlaggedOnly") === "true";
 
111
  return false;
112
  });
113
  const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(() => {
114
- if (typeof window !== "undefined") return sessionStorage.getItem("sidebarFlaggedOnly") === "true";
 
115
  return false;
116
  });
117
- const [crossEpData, setCrossEpData] = useState<CrossEpisodeVarianceData | null>(null);
 
118
  const [insightsLoading, setInsightsLoading] = useState(false);
119
  const insightsLoadedRef = useRef(false);
120
 
121
  // Persist UI state across episode navigations
122
- useEffect(() => { sessionStorage.setItem("activeTab", activeTab); }, [activeTab]);
123
- useEffect(() => { sessionStorage.setItem("sidebarFlaggedOnly", String(sidebarFlaggedOnly)); }, [sidebarFlaggedOnly]);
124
- useEffect(() => { sessionStorage.setItem("framesFlaggedOnly", String(framesFlaggedOnly)); }, [framesFlaggedOnly]);
 
 
 
 
 
 
125
 
126
  const loadStats = () => {
127
  if (statsLoadedRef.current) return;
@@ -163,7 +206,10 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
163
  if (activeTab === "statistics") loadStats();
164
  if (activeTab === "frames") loadFrames();
165
  if (activeTab === "insights") loadInsights();
166
- if (activeTab === "filtering") { loadStats(); loadInsights(); }
 
 
 
167
  // eslint-disable-next-line react-hooks/exhaustive-deps
168
  }, []);
169
 
@@ -172,7 +218,10 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
172
  if (tab === "statistics") loadStats();
173
  if (tab === "frames") loadFrames();
174
  if (tab === "insights") loadInsights();
175
- if (tab === "filtering") { loadStats(); loadInsights(); }
 
 
 
176
  };
177
 
178
  // Use context for time sync
@@ -186,7 +235,7 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
186
  (currentPage - 1) * pageSize,
187
  currentPage * pageSize,
188
  );
189
-
190
  // Preload adjacent episodes' videos via <link rel="preload"> tags
191
  useEffect(() => {
192
  if (!org || !dataset) return;
@@ -370,21 +419,22 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
370
  <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
371
  )}
372
  </button>
373
- {hasURDFSupport(datasetInfo.robot_type) && datasetInfo.codebase_version >= "v3.0" && (
374
- <button
375
- className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
376
- activeTab === "urdf"
377
- ? "text-orange-400"
378
- : "text-slate-400 hover:text-slate-200"
379
- }`}
380
- onClick={() => handleTabChange("urdf")}
381
- >
382
- 3D Replay
383
- {activeTab === "urdf" && (
384
- <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
385
- )}
386
- </button>
387
- )}
 
388
  </div>
389
 
390
  {/* Body: sidebar + content */}
@@ -430,7 +480,9 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
430
  href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
431
  target="_blank"
432
  >
433
- <p className="text-lg font-semibold">{datasetInfo.repoId}</p>
 
 
434
  </a>
435
 
436
  <p className="font-mono text-lg font-semibold">
@@ -451,14 +503,18 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
451
  {task && (
452
  <div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
453
  <p className="text-slate-300">
454
- <span className="font-semibold text-slate-100">Language Instruction:</span>
 
 
455
  </p>
456
  <div className="mt-2 text-slate-300">
457
- {task.split('\n').map((instruction: string, index: number) => (
458
- <p key={index} className="mb-1">
459
- {instruction}
460
- </p>
461
- ))}
 
 
462
  </div>
463
  </div>
464
  )}
@@ -484,7 +540,12 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
484
  )}
485
 
486
  {activeTab === "frames" && (
487
- <OverviewPanel data={episodeFramesData} loading={framesLoading} flaggedOnly={framesFlaggedOnly} onFlaggedOnlyChange={setFramesFlaggedOnly} />
 
 
 
 
 
488
  )}
489
 
490
  {activeTab === "insights" && (
 
22
  type EpisodeFramesData,
23
  type CrossEpisodeVarianceData,
24
  } from "./fetch-data";
25
+ import {
26
+ fetchEpisodeLengthStats,
27
+ fetchEpisodeFrames,
28
+ fetchCrossEpisodeVariance,
29
+ } from "./actions";
30
 
31
  const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
32
+ const ActionInsightsPanel = lazy(
33
+ () => import("@/components/action-insights-panel"),
34
+ );
35
  const FilteringPanel = lazy(() => import("@/components/filtering-panel"));
36
 
37
+ type ActiveTab =
38
+ | "episodes"
39
+ | "statistics"
40
+ | "frames"
41
+ | "insights"
42
+ | "filtering"
43
+ | "urdf";
44
 
45
  export default function EpisodeViewer({
46
  data,
 
77
  );
78
  }
79
 
80
+ function EpisodeViewerInner({
81
+ data,
82
+ org,
83
+ dataset,
84
+ }: {
85
+ data: EpisodeData;
86
+ org?: string;
87
+ dataset?: string;
88
+ }) {
89
  const {
90
  datasetInfo,
91
  episodeId,
 
102
  const loadStartRef = useRef(performance.now());
103
  useEffect(() => {
104
  if (!isLoading) {
105
+ console.log(
106
+ `[perf] Loading complete in ${(performance.now() - loadStartRef.current).toFixed(0)}ms (videos: ${videosReady ? "✓" : "…"}, charts: ${chartsReady ? "✓" : "…"})`,
107
+ );
108
  }
109
  }, [isLoading]);
110
 
 
115
  const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
116
  if (typeof window !== "undefined") {
117
  const stored = sessionStorage.getItem("activeTab");
118
+ if (
119
+ stored &&
120
+ [
121
+ "episodes",
122
+ "statistics",
123
+ "frames",
124
+ "insights",
125
+ "filtering",
126
+ "urdf",
127
+ ].includes(stored)
128
+ ) {
129
  return stored as ActiveTab;
130
  }
131
  }
132
  return "episodes";
133
  });
134
  const [, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
135
+ const [episodeLengthStats, setEpisodeLengthStats] =
136
+ useState<EpisodeLengthStats | null>(null);
137
  const [statsLoading, setStatsLoading] = useState(false);
138
  const statsLoadedRef = useRef(false);
139
+ const [episodeFramesData, setEpisodeFramesData] =
140
+ useState<EpisodeFramesData | null>(null);
141
  const [framesLoading, setFramesLoading] = useState(false);
142
  const framesLoadedRef = useRef(false);
143
  const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(() => {
144
+ if (typeof window !== "undefined")
145
+ return sessionStorage.getItem("framesFlaggedOnly") === "true";
146
  return false;
147
  });
148
  const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(() => {
149
+ if (typeof window !== "undefined")
150
+ return sessionStorage.getItem("sidebarFlaggedOnly") === "true";
151
  return false;
152
  });
153
+ const [crossEpData, setCrossEpData] =
154
+ useState<CrossEpisodeVarianceData | null>(null);
155
  const [insightsLoading, setInsightsLoading] = useState(false);
156
  const insightsLoadedRef = useRef(false);
157
 
158
  // Persist UI state across episode navigations
159
+ useEffect(() => {
160
+ sessionStorage.setItem("activeTab", activeTab);
161
+ }, [activeTab]);
162
+ useEffect(() => {
163
+ sessionStorage.setItem("sidebarFlaggedOnly", String(sidebarFlaggedOnly));
164
+ }, [sidebarFlaggedOnly]);
165
+ useEffect(() => {
166
+ sessionStorage.setItem("framesFlaggedOnly", String(framesFlaggedOnly));
167
+ }, [framesFlaggedOnly]);
168
 
169
  const loadStats = () => {
170
  if (statsLoadedRef.current) return;
 
206
  if (activeTab === "statistics") loadStats();
207
  if (activeTab === "frames") loadFrames();
208
  if (activeTab === "insights") loadInsights();
209
+ if (activeTab === "filtering") {
210
+ loadStats();
211
+ loadInsights();
212
+ }
213
  // eslint-disable-next-line react-hooks/exhaustive-deps
214
  }, []);
215
 
 
218
  if (tab === "statistics") loadStats();
219
  if (tab === "frames") loadFrames();
220
  if (tab === "insights") loadInsights();
221
+ if (tab === "filtering") {
222
+ loadStats();
223
+ loadInsights();
224
+ }
225
  };
226
 
227
  // Use context for time sync
 
235
  (currentPage - 1) * pageSize,
236
  currentPage * pageSize,
237
  );
238
+
239
  // Preload adjacent episodes' videos via <link rel="preload"> tags
240
  useEffect(() => {
241
  if (!org || !dataset) return;
 
419
  <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
420
  )}
421
  </button>
422
+ {hasURDFSupport(datasetInfo.robot_type) &&
423
+ datasetInfo.codebase_version >= "v3.0" && (
424
+ <button
425
+ className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
426
+ activeTab === "urdf"
427
+ ? "text-orange-400"
428
+ : "text-slate-400 hover:text-slate-200"
429
+ }`}
430
+ onClick={() => handleTabChange("urdf")}
431
+ >
432
+ 3D Replay
433
+ {activeTab === "urdf" && (
434
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
435
+ )}
436
+ </button>
437
+ )}
438
  </div>
439
 
440
  {/* Body: sidebar + content */}
 
480
  href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
481
  target="_blank"
482
  >
483
+ <p className="text-lg font-semibold">
484
+ {datasetInfo.repoId}
485
+ </p>
486
  </a>
487
 
488
  <p className="font-mono text-lg font-semibold">
 
503
  {task && (
504
  <div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
505
  <p className="text-slate-300">
506
+ <span className="font-semibold text-slate-100">
507
+ Language Instruction:
508
+ </span>
509
  </p>
510
  <div className="mt-2 text-slate-300">
511
+ {task
512
+ .split("\n")
513
+ .map((instruction: string, index: number) => (
514
+ <p key={index} className="mb-1">
515
+ {instruction}
516
+ </p>
517
+ ))}
518
  </div>
519
  </div>
520
  )}
 
540
  )}
541
 
542
  {activeTab === "frames" && (
543
+ <OverviewPanel
544
+ data={episodeFramesData}
545
+ loading={framesLoading}
546
+ flaggedOnly={framesFlaggedOnly}
547
+ onFlaggedOnlyChange={setFramesFlaggedOnly}
548
+ />
549
  )}
550
 
551
  {activeTab === "insights" && (
src/app/[org]/[dataset]/[episode]/fetch-data.ts CHANGED
@@ -5,7 +5,10 @@ import {
5
  readParquetAsObjects,
6
  } from "@/utils/parquetUtils";
7
  import { pick } from "@/utils/pick";
8
- import { getDatasetVersionAndInfo, buildVersionedUrl } from "@/utils/versionUtils";
 
 
 
9
  import { PADDING, CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants";
10
  import {
11
  processChartDataGroups,
@@ -121,9 +124,10 @@ export async function getEpisodeData(
121
  }
122
 
123
  console.time(`[perf] getEpisodeData (${version})`);
124
- const result = version === "v3.0"
125
- ? await getEpisodeDataV3(repoId, version, info, episodeId)
126
- : await getEpisodeDataV2(repoId, version, info, episodeId);
 
127
  console.timeEnd(`[perf] getEpisodeData (${version})`);
128
 
129
  // Extract camera resolutions from features
@@ -136,7 +140,12 @@ export async function getEpisodeData(
136
  robot_type: rawInfo.robot_type ?? null,
137
  codebase_version: rawInfo.codebase_version,
138
  total_tasks: rawInfo.total_tasks ?? 0,
139
- dataset_size_mb: Math.round(((rawInfo.data_files_size_in_mb ?? 0) + (rawInfo.video_files_size_in_mb ?? 0)) * 10) / 10,
 
 
 
 
 
140
  cameras,
141
  };
142
 
@@ -157,19 +166,19 @@ export async function getAdjacentEpisodesVideoInfo(
157
  try {
158
  const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId);
159
  const info = rawInfo as unknown as DatasetMetadata;
160
-
161
  const totalEpisodes = info.total_episodes;
162
  const adjacentVideos: AdjacentEpisodeVideos[] = [];
163
-
164
  // Calculate adjacent episode IDs
165
  for (let offset = -radius; offset <= radius; offset++) {
166
  if (offset === 0) continue; // Skip current episode
167
-
168
  const episodeId = currentEpisodeId + offset;
169
  if (episodeId >= 0 && episodeId < totalEpisodes) {
170
  try {
171
  let videosInfo: VideoInfo[] = [];
172
-
173
  if (version === "v3.0") {
174
  const episodeMetadata = await loadEpisodeMetadataV3Simple(
175
  repoId,
@@ -185,34 +194,34 @@ export async function getAdjacentEpisodesVideoInfo(
185
  } else {
186
  // For v2.x, use simpler video info extraction
187
  if (info.video_path) {
188
- const episode_chunk = Math.floor(0 / 1000);
189
- videosInfo = Object.entries(info.features)
190
- .filter(([, value]) => value.dtype === "video")
191
- .map(([key]) => {
192
  const videoPath = formatStringWithVars(info.video_path!, {
193
- video_key: key,
194
  episode_chunk: episode_chunk
195
  .toString()
196
  .padStart(PADDING.CHUNK_INDEX, "0"),
197
  episode_index: episodeId
198
  .toString()
199
  .padStart(PADDING.EPISODE_INDEX, "0"),
 
 
 
 
 
200
  });
201
- return {
202
- filename: key,
203
- url: buildVersionedUrl(repoId, version, videoPath),
204
- };
205
- });
206
  }
207
  }
208
-
209
  adjacentVideos.push({ episodeId, videosInfo });
210
- } catch {
211
- // Skip failed episodes silently
212
- }
213
  }
214
  }
215
-
216
  return adjacentVideos;
217
  } catch {
218
  // Return empty array on error
@@ -253,25 +262,25 @@ async function getEpisodeDataV2(
253
  .map((x) => parseInt(x.trim(), 10))
254
  .filter((x) => !isNaN(x));
255
 
256
- // Videos information
257
  const videosInfo =
258
  info.video_path !== null
259
  ? Object.entries(info.features)
260
- .filter(([, value]) => value.dtype === "video")
261
- .map(([key]) => {
262
  const videoPath = formatStringWithVars(info.video_path!, {
263
- video_key: key,
264
  episode_chunk: episode_chunk
265
  .toString()
266
  .padStart(PADDING.CHUNK_INDEX, "0"),
267
  episode_index: episodeId
268
  .toString()
269
  .padStart(PADDING.EPISODE_INDEX, "0"),
270
- });
271
- return {
272
- filename: key,
273
- url: buildVersionedUrl(repoId, version, videoPath),
274
- };
275
  })
276
  : [];
277
 
@@ -297,7 +306,9 @@ async function getEpisodeDataV2(
297
  return {
298
  key,
299
  value: Array.isArray(column_names)
300
- ? column_names.map((name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`)
 
 
301
  : Array.from(
302
  { length: columnNames.find((c) => c.key === key)?.length ?? 1 },
303
  (_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
@@ -318,49 +329,56 @@ async function getEpisodeDataV2(
318
 
319
  const arrayBuffer = await fetchParquetFile(parquetUrl);
320
  const allData = await readParquetAsObjects(arrayBuffer, []);
321
-
322
  // Extract task from language_instruction fields, task field, or tasks.jsonl
323
  let task: string | undefined;
324
-
325
  if (allData.length > 0) {
326
  const firstRow = allData[0];
327
  const languageInstructions: string[] = [];
328
-
329
- if (typeof firstRow.language_instruction === 'string') {
330
  languageInstructions.push(firstRow.language_instruction);
331
  }
332
-
333
  let instructionNum = 2;
334
- while (typeof firstRow[`language_instruction_${instructionNum}`] === 'string') {
335
- languageInstructions.push(firstRow[`language_instruction_${instructionNum}`] as string);
 
 
 
 
336
  instructionNum++;
337
  }
338
-
339
  if (languageInstructions.length > 0) {
340
- task = languageInstructions.join('\n');
341
  }
342
  }
343
-
344
- if (!task && allData.length > 0 && typeof allData[0].task === 'string') {
345
  task = allData[0].task;
346
  }
347
-
348
  if (!task && allData.length > 0) {
349
  try {
350
  const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
351
  const tasksResponse = await fetch(tasksUrl);
352
-
353
  if (tasksResponse.ok) {
354
  const tasksText = await tasksResponse.text();
355
  const tasksData = tasksText
356
  .split("\n")
357
  .filter((line) => line.trim())
358
  .map((line) => JSON.parse(line));
359
-
360
  if (tasksData && tasksData.length > 0) {
361
  const taskIndex = allData[0].task_index;
362
- const taskIndexNum = typeof taskIndex === 'bigint' ? Number(taskIndex) : taskIndex;
363
- const taskData = tasksData.find((t: Record<string, unknown>) => t.task_index === taskIndexNum);
 
 
 
364
  if (taskData) {
365
  task = taskData.task;
366
  }
@@ -370,7 +388,7 @@ async function getEpisodeDataV2(
370
  // No tasks metadata file for this v2.x dataset
371
  }
372
  }
373
-
374
  // Build chart data from already-parsed allData (no second parquet parse)
375
  const seriesNames = [
376
  "timestamp",
@@ -385,7 +403,7 @@ async function getEpisodeDataV2(
385
  if (Array.isArray(rawVal)) {
386
  rawVal.forEach((v: unknown, i: number) => {
387
  if (i < col.value.length) obj[col.value[i]] = Number(v);
388
- });
389
  } else if (rawVal !== undefined) {
390
  obj[col.value[0]] = Number(rawVal);
391
  }
@@ -458,7 +476,7 @@ async function getEpisodeDataV3(
458
  version,
459
  episodeId,
460
  );
461
-
462
  // Create video info with segmentation using the metadata
463
  const videosInfo = extractVideoInfoV3WithSegmentation(
464
  repoId,
@@ -468,10 +486,12 @@ async function getEpisodeDataV3(
468
  );
469
 
470
  // Load episode data for charts
471
- const { chartDataGroups, flatChartData, ignoredColumns, task } = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
 
472
 
473
- const duration = episodeMetadata.length ? episodeMetadata.length / info.fps :
474
- (episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp);
 
475
 
476
  return {
477
  datasetInfo,
@@ -492,96 +512,124 @@ async function loadEpisodeDataV3(
492
  version: string,
493
  info: DatasetMetadata,
494
  episodeMetadata: EpisodeMetadataV3,
495
- ): Promise<{ chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[]; ignoredColumns: string[]; task?: string }> {
 
 
 
 
 
496
  // Build data file path using chunk and file indices
497
  const dataChunkIndex = bigIntToNumber(episodeMetadata.data_chunk_index, 0);
498
  const dataFileIndex = bigIntToNumber(episodeMetadata.data_file_index, 0);
499
  const dataPath = buildV3DataPath(dataChunkIndex, dataFileIndex);
500
-
501
  try {
502
  const dataUrl = buildVersionedUrl(repoId, version, dataPath);
503
  const arrayBuffer = await fetchParquetFile(dataUrl);
504
  const fullData = await readParquetAsObjects(arrayBuffer, []);
505
-
506
  // Extract the episode-specific data slice
507
  const fromIndex = bigIntToNumber(episodeMetadata.dataset_from_index, 0);
508
- const toIndex = bigIntToNumber(episodeMetadata.dataset_to_index, fullData.length);
509
-
 
 
 
510
  // Find the starting index of this parquet file by checking the first row's index
511
  // This handles the case where episodes are split across multiple parquet files
512
  let fileStartIndex = 0;
513
  if (fullData.length > 0 && fullData[0].index !== undefined) {
514
  fileStartIndex = Number(fullData[0].index);
515
  }
516
-
517
  // Adjust indices to be relative to this file's starting position
518
  const localFromIndex = Math.max(0, fromIndex - fileStartIndex);
519
  const localToIndex = Math.min(fullData.length, toIndex - fileStartIndex);
520
-
521
  const episodeData = fullData.slice(localFromIndex, localToIndex);
522
-
523
  if (episodeData.length === 0) {
524
- return { chartDataGroups: [], flatChartData: [], ignoredColumns: [], task: undefined };
 
 
 
 
 
525
  }
526
-
527
  // Convert to the same format as v2.x for compatibility with existing chart code
528
- const { chartDataGroups, flatChartData, ignoredColumns } = processEpisodeDataForCharts(episodeData, info, episodeMetadata);
529
-
 
530
  // First check for language_instruction fields in the data (preferred)
531
  let task: string | undefined;
532
  if (episodeData.length > 0) {
533
  const languageInstructions: string[] = [];
534
-
535
  const extractInstructions = (row: Record<string, unknown>) => {
536
- if (typeof row.language_instruction === 'string') {
537
  languageInstructions.push(row.language_instruction);
538
  }
539
  let num = 2;
540
- while (typeof row[`language_instruction_${num}`] === 'string') {
541
- languageInstructions.push(row[`language_instruction_${num}`] as string);
 
 
542
  num++;
543
  }
544
  };
545
 
546
  extractInstructions(episodeData[0]);
547
-
548
  // If no instructions in first row, check middle and last rows
549
  if (languageInstructions.length === 0 && episodeData.length > 1) {
550
- for (const idx of [Math.floor(episodeData.length / 2), episodeData.length - 1]) {
 
 
 
551
  extractInstructions(episodeData[idx]);
552
  if (languageInstructions.length > 0) break;
553
  }
554
  }
555
-
556
  if (languageInstructions.length > 0) {
557
- task = languageInstructions.join('\n');
558
  }
559
  }
560
-
561
  // Fall back to tasks metadata parquet
562
  if (!task && episodeData.length > 0) {
563
  try {
564
- const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.parquet");
 
 
 
 
565
  const tasksArrayBuffer = await fetchParquetFile(tasksUrl);
566
  const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
567
-
568
  if (tasksData.length > 0) {
569
  const taskIndexNum = bigIntToNumber(episodeData[0].task_index, -1);
570
 
571
  if (taskIndexNum >= 0 && taskIndexNum < tasksData.length) {
572
  const taskData = tasksData[taskIndexNum];
573
  const rawTask = taskData.__index_level_0__ ?? taskData.task;
574
- task = typeof rawTask === 'string' ? rawTask : undefined;
575
  }
576
  }
577
  } catch {
578
  // Could not load tasks metadata
579
  }
580
  }
581
-
582
  return { chartDataGroups, flatChartData, ignoredColumns, task };
583
  } catch {
584
- return { chartDataGroups: [], flatChartData: [], ignoredColumns: [], task: undefined };
 
 
 
 
 
585
  }
586
  }
587
 
@@ -590,17 +638,20 @@ function processEpisodeDataForCharts(
590
  episodeData: Record<string, unknown>[],
591
  info: DatasetMetadata,
592
  episodeMetadata?: EpisodeMetadataV3,
593
- ): { chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[]; ignoredColumns: string[] } {
594
-
 
 
 
595
  // Convert parquet data to chart format
596
  let seriesNames: string[] = [];
597
-
598
  // Dynamically create a mapping from numeric indices to feature names based on actual dataset features
599
  const v3IndexToFeatureMap: Record<string, string> = {};
600
-
601
  // Build mapping based on what features actually exist in the dataset
602
  const featureKeys = Object.keys(info.features);
603
-
604
  // Common feature order for v3.0 datasets (but only include if they exist)
605
  const expectedFeatureOrder = [
606
  "observation.state",
@@ -613,7 +664,7 @@ function processEpisodeDataForCharts(
613
  "index",
614
  "task_index",
615
  ];
616
-
617
  // Map indices to features that actually exist
618
  let currentIndex = 0;
619
  expectedFeatureOrder.forEach((feature) => {
@@ -622,16 +673,17 @@ function processEpisodeDataForCharts(
622
  currentIndex++;
623
  }
624
  });
625
-
626
  // Columns to exclude from charts (note: 'task' is intentionally not excluded as we want to access it)
627
  const excludedColumns = EXCLUDED_COLUMNS.V3 as readonly string[];
628
 
629
  // Create columns structure similar to V2.1 for proper hierarchical naming
630
  const columns: ColumnDef[] = Object.entries(info.features)
631
- .filter(([key, value]) =>
632
- ["float32", "int32"].includes(value.dtype) &&
633
- value.shape.length === 1 &&
634
- !excludedColumns.includes(key)
 
635
  )
636
  .map(([key, feature]) => {
637
  let column_names: unknown = feature.names;
@@ -642,7 +694,9 @@ function processEpisodeDataForCharts(
642
  return {
643
  key,
644
  value: Array.isArray(column_names)
645
- ? column_names.map((name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`)
 
 
646
  : Array.from(
647
  { length: feature.shape[0] || 1 },
648
  (_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
@@ -654,19 +708,19 @@ function processEpisodeDataForCharts(
654
  if (episodeData.length > 0) {
655
  const firstRow = episodeData[0];
656
  const allKeys: string[] = [];
657
-
658
  Object.entries(firstRow || {}).forEach(([key, value]) => {
659
  if (key === "timestamp") return; // Skip timestamp, we'll add it separately
660
-
661
  // Map numeric key to feature name if available
662
  const featureName = v3IndexToFeatureMap[key] || key;
663
-
664
  // Skip if feature doesn't exist in dataset
665
  if (!info.features[featureName]) return;
666
-
667
  // Skip excluded columns
668
  if (excludedColumns.includes(featureName)) return;
669
-
670
  // Find the matching column definition to get proper names
671
  const columnDef = columns.find((col) => col.key === featureName);
672
  if (columnDef && Array.isArray(value) && value.length > 0) {
@@ -684,7 +738,7 @@ function processEpisodeDataForCharts(
684
  allKeys.push(featureName);
685
  }
686
  });
687
-
688
  seriesNames = ["timestamp", ...allKeys];
689
  } else {
690
  // Fallback to column-based approach like V2.1
@@ -693,7 +747,7 @@ function processEpisodeDataForCharts(
693
 
694
  const chartData = episodeData.map((row, index) => {
695
  const obj: Record<string, number> = {};
696
-
697
  // Add timestamp aligned with video timing
698
  // For v3.0, we need to map the episode data index to the actual video duration
699
  let videoDuration = episodeData.length; // Fallback to data length
@@ -705,7 +759,7 @@ function processEpisodeDataForCharts(
705
  }
706
  obj["timestamp"] =
707
  (index / Math.max(episodeData.length - 1, 1)) * videoDuration;
708
-
709
  // Add all data columns using hierarchical naming
710
  if (row && typeof row === "object") {
711
  Object.entries(row).forEach(([key, value]) => {
@@ -713,19 +767,19 @@ function processEpisodeDataForCharts(
713
  // Timestamp is already handled above
714
  return;
715
  }
716
-
717
  // Map numeric key to feature name if available
718
  const featureName = v3IndexToFeatureMap[key] || key;
719
-
720
  // Skip if feature doesn't exist in dataset
721
  if (!info.features[featureName]) return;
722
-
723
  // Skip excluded columns
724
  if (excludedColumns.includes(featureName)) return;
725
-
726
  // Find the matching column definition to get proper series names
727
  const columnDef = columns.find((col) => col.key === featureName);
728
-
729
  if (Array.isArray(value) && columnDef) {
730
  // For array values like observation.state and action, use proper hierarchical naming
731
  value.forEach((val, idx) => {
@@ -744,7 +798,7 @@ function processEpisodeDataForCharts(
744
  }
745
  });
746
  }
747
-
748
  return obj;
749
  });
750
 
@@ -794,23 +848,29 @@ function extractVideoInfoV3WithSegmentation(
794
  const cameraSpecificKeys = Object.keys(episodeMetadata).filter((key) =>
795
  key.startsWith(`videos/${videoKey}/`),
796
  );
797
-
798
- let chunkIndex: number, fileIndex: number, segmentStart: number, segmentEnd: number;
799
-
800
- const toNum = (v: string | number): number => typeof v === 'string' ? parseFloat(v) || 0 : v;
 
 
 
 
801
 
802
  if (cameraSpecificKeys.length > 0) {
803
  chunkIndex = toNum(episodeMetadata[`videos/${videoKey}/chunk_index`]);
804
  fileIndex = toNum(episodeMetadata[`videos/${videoKey}/file_index`]);
805
- segmentStart = toNum(episodeMetadata[`videos/${videoKey}/from_timestamp`]) || 0;
806
- segmentEnd = toNum(episodeMetadata[`videos/${videoKey}/to_timestamp`]) || 30;
 
 
807
  } else {
808
  chunkIndex = episodeMetadata.video_chunk_index || 0;
809
  fileIndex = episodeMetadata.video_file_index || 0;
810
  segmentStart = episodeMetadata.video_from_timestamp || 0;
811
  segmentEnd = episodeMetadata.video_to_timestamp || 30;
812
  }
813
-
814
  // Convert BigInt to number for timestamps
815
  const startNum = bigIntToNumber(segmentStart);
816
  const endNum = bigIntToNumber(segmentEnd);
@@ -821,7 +881,7 @@ function extractVideoInfoV3WithSegmentation(
821
  bigIntToNumber(fileIndex, 0),
822
  );
823
  const fullUrl = buildVersionedUrl(repoId, version, videoPath);
824
-
825
  return {
826
  filename: videoKey,
827
  url: fullUrl,
@@ -844,11 +904,11 @@ async function loadEpisodeMetadataV3Simple(
844
  ): Promise<EpisodeMetadataV3> {
845
  // Pattern: meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet
846
  // Most datasets have all episodes in chunk-000/file-000, but episodes can be split across files
847
-
848
  let episodeRow = null;
849
  let fileIndex = 0;
850
  const chunkIndex = 0; // Episodes are typically in chunk-000
851
-
852
  // Try loading episode metadata files until we find the episode
853
  while (!episodeRow) {
854
  const episodesMetadataPath = buildV3EpisodesMetadataPath(
@@ -864,23 +924,23 @@ async function loadEpisodeMetadataV3Simple(
864
  try {
865
  const arrayBuffer = await fetchParquetFile(episodesMetadataUrl);
866
  const episodesData = await readParquetAsObjects(arrayBuffer, []);
867
-
868
  if (episodesData.length === 0) {
869
  // Empty file, try next one
870
  fileIndex++;
871
  continue;
872
  }
873
-
874
  // Find the row for the requested episode by episode_index
875
  for (const row of episodesData) {
876
  const parsedRow = parseEpisodeRowSimple(row);
877
-
878
  if (parsedRow.episode_index === episodeId) {
879
  episodeRow = row;
880
  break;
881
  }
882
  }
883
-
884
  if (!episodeRow) {
885
  // Not in this file, try the next one
886
  fileIndex++;
@@ -892,13 +952,15 @@ async function loadEpisodeMetadataV3Simple(
892
  );
893
  }
894
  }
895
-
896
  // Convert the row to a usable format
897
  return parseEpisodeRowSimple(episodeRow);
898
  }
899
 
900
  // Simple parser for episode row - focuses on key fields for episodes
901
- function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3 {
 
 
902
  // v3.0 uses named keys in the episode metadata
903
  if (row && typeof row === "object") {
904
  // Check if this is v3.0 format with named keys
@@ -906,24 +968,29 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
906
  // v3.0 format - use named keys
907
  // Convert BigInt values to numbers
908
  const toBigIntSafe = (value: unknown): number => {
909
- if (typeof value === 'bigint') return Number(value);
910
- if (typeof value === 'number') return value;
911
- if (typeof value === 'string') return parseInt(value) || 0;
912
  return 0;
913
  };
914
-
915
  const toNumSafe = (value: unknown): number => {
916
- if (typeof value === 'number') return value;
917
- if (typeof value === 'bigint') return Number(value);
918
- if (typeof value === 'string') return parseFloat(value) || 0;
919
  return 0;
920
  };
921
 
922
  // Handle video metadata - look for video-specific keys
923
- const videoKeys = Object.keys(row).filter(key => key.includes('videos/') && key.includes('/chunk_index'));
924
- let videoChunkIndex = 0, videoFileIndex = 0, videoFromTs = 0, videoToTs = 30;
 
 
 
 
 
925
  if (videoKeys.length > 0) {
926
- const videoBaseName = videoKeys[0].replace('/chunk_index', '');
927
  videoChunkIndex = toBigIntSafe(row[`${videoBaseName}/chunk_index`]);
928
  videoFileIndex = toBigIntSafe(row[`${videoBaseName}/file_index`]);
929
  videoFromTs = toNumSafe(row[`${videoBaseName}/from_timestamp`]);
@@ -931,46 +998,55 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
931
  }
932
 
933
  const episodeData: EpisodeMetadataV3 = {
934
- episode_index: toBigIntSafe(row['episode_index']),
935
- data_chunk_index: toBigIntSafe(row['data/chunk_index']),
936
- data_file_index: toBigIntSafe(row['data/file_index']),
937
- dataset_from_index: toBigIntSafe(row['dataset_from_index']),
938
- dataset_to_index: toBigIntSafe(row['dataset_to_index']),
939
- length: toBigIntSafe(row['length']),
940
  video_chunk_index: videoChunkIndex,
941
  video_file_index: videoFileIndex,
942
  video_from_timestamp: videoFromTs,
943
  video_to_timestamp: videoToTs,
944
  };
945
-
946
  // Store per-camera metadata for extractVideoInfoV3WithSegmentation
947
- Object.keys(row).forEach(key => {
948
- if (key.startsWith('videos/')) {
949
  const val = row[key];
950
- episodeData[key] = typeof val === 'bigint' ? Number(val) : (typeof val === 'number' || typeof val === 'string' ? val : 0);
 
 
 
 
 
951
  }
952
  });
953
-
954
  return episodeData as EpisodeMetadataV3;
955
  } else {
956
  // Fallback to numeric keys for compatibility
957
  const toNum = (v: unknown, fallback = 0): number =>
958
- typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : fallback;
 
 
 
 
959
  return {
960
- episode_index: toNum(row['0']),
961
- data_chunk_index: toNum(row['1']),
962
- data_file_index: toNum(row['2']),
963
- dataset_from_index: toNum(row['3']),
964
- dataset_to_index: toNum(row['4']),
965
- video_chunk_index: toNum(row['5']),
966
- video_file_index: toNum(row['6']),
967
- video_from_timestamp: toNum(row['7']),
968
- video_to_timestamp: toNum(row['8'], 30),
969
- length: toNum(row['9'], 30),
970
  };
971
  }
972
  }
973
-
974
  // Fallback if parsing fails
975
  const fallback = {
976
  episode_index: 0,
@@ -984,18 +1060,18 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
984
  video_to_timestamp: 30,
985
  length: 30,
986
  };
987
-
988
  return fallback;
989
  }
990
 
991
-
992
-
993
  // ─── Stats computation ───────────────────────────────────────────
994
 
995
  /**
996
  * Compute per-column min/max values from the current episode's chart data.
997
  */
998
- export function computeColumnMinMax(chartDataGroups: ChartRow[][]): ColumnMinMax[] {
 
 
999
  const stats: Record<string, { min: number; max: number }> = {};
1000
 
1001
  for (const group of chartDataGroups) {
@@ -1057,7 +1133,10 @@ export async function loadAllEpisodeLengthsV3(
1057
  if (rows.length === 0 && fileIndex > 0) break;
1058
  for (const row of rows) {
1059
  const parsed = parseEpisodeRowSimple(row);
1060
- allEpisodes.push({ index: parsed.episode_index, length: parsed.length });
 
 
 
1061
  }
1062
  fileIndex++;
1063
  } catch {
@@ -1073,7 +1152,9 @@ export async function loadAllEpisodeLengthsV3(
1073
  lengthSeconds: Math.round((ep.length / fps) * 100) / 100,
1074
  }));
1075
 
1076
- const sortedByLength = [...withSeconds].sort((a, b) => a.lengthSeconds - b.lengthSeconds);
 
 
1077
  const shortestEpisodes = sortedByLength.slice(0, 5);
1078
  const longestEpisodes = sortedByLength.slice(-5).reverse();
1079
 
@@ -1083,11 +1164,13 @@ export async function loadAllEpisodeLengthsV3(
1083
 
1084
  const sorted = [...lengths].sort((a, b) => a - b);
1085
  const mid = Math.floor(sorted.length / 2);
1086
- const median = sorted.length % 2 === 0
1087
- ? Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 100) / 100
1088
- : sorted[mid];
 
1089
 
1090
- const variance = lengths.reduce((acc, l) => acc + (l - mean) ** 2, 0) / lengths.length;
 
1091
  const std = Math.round(Math.sqrt(variance) * 100) / 100;
1092
 
1093
  // Build histogram
@@ -1096,25 +1179,39 @@ export async function loadAllEpisodeLengthsV3(
1096
 
1097
  if (histMax === histMin) {
1098
  return {
1099
- shortestEpisodes, longestEpisodes, allEpisodeLengths: withSeconds,
1100
- meanEpisodeLength: mean, medianEpisodeLength: median, stdEpisodeLength: std,
1101
- episodeLengthHistogram: [{ binLabel: `${histMin.toFixed(1)}s`, count: lengths.length }],
 
 
 
 
 
 
1102
  };
1103
  }
1104
 
1105
  const p1 = sorted[Math.floor(sorted.length * 0.01)];
1106
  const p99 = sorted[Math.ceil(sorted.length * 0.99) - 1];
1107
- const range = (p99 - p1) || 1;
1108
 
1109
- const targetBins = Math.max(10, Math.min(50, Math.ceil(Math.log2(lengths.length) + 1)));
 
 
 
1110
  const rawBinWidth = range / targetBins;
1111
  const magnitude = Math.pow(10, Math.floor(Math.log10(rawBinWidth)));
1112
  const niceSteps = [1, 2, 2.5, 5, 10];
1113
- const niceBinWidth = niceSteps.map((s) => s * magnitude).find((w) => w >= rawBinWidth) ?? rawBinWidth;
 
 
1114
 
1115
  const niceMin = Math.floor(p1 / niceBinWidth) * niceBinWidth;
1116
  const niceMax = Math.ceil(p99 / niceBinWidth) * niceBinWidth;
1117
- const actualBinCount = Math.max(1, Math.round((niceMax - niceMin) / niceBinWidth));
 
 
 
1118
  const bins = Array.from({ length: actualBinCount }, () => 0);
1119
 
1120
  for (const len of lengths) {
@@ -1131,8 +1228,12 @@ export async function loadAllEpisodeLengthsV3(
1131
  });
1132
 
1133
  return {
1134
- shortestEpisodes, longestEpisodes, allEpisodeLengths: withSeconds,
1135
- meanEpisodeLength: mean, medianEpisodeLength: median, stdEpisodeLength: std,
 
 
 
 
1136
  episodeLengthHistogram: histogram,
1137
  };
1138
  } catch {
@@ -1149,7 +1250,9 @@ export async function loadAllEpisodeFrameInfo(
1149
  version: string,
1150
  info: DatasetMetadata,
1151
  ): Promise<EpisodeFramesData> {
1152
- const videoFeatures = Object.entries(info.features).filter(([, f]) => f.dtype === "video");
 
 
1153
  if (videoFeatures.length === 0) return { cameras: [], framesByCamera: {} };
1154
 
1155
  const cameras = videoFeatures.map(([key]) => key);
@@ -1161,16 +1264,30 @@ export async function loadAllEpisodeFrameInfo(
1161
  while (true) {
1162
  const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
1163
  try {
1164
- const buf = await fetchParquetFile(buildVersionedUrl(repoId, version, path));
 
 
1165
  const rows = await readParquetAsObjects(buf, []);
1166
  if (rows.length === 0 && fileIndex > 0) break;
1167
  for (const row of rows) {
1168
  const epIdx = Number(row["episode_index"] ?? 0);
1169
  for (const cam of cameras) {
1170
- const cIdx = Number(row[`videos/${cam}/chunk_index`] ?? row["video_chunk_index"] ?? 0);
1171
- const fIdx = Number(row[`videos/${cam}/file_index`] ?? row["video_file_index"] ?? 0);
1172
- const fromTs = Number(row[`videos/${cam}/from_timestamp`] ?? row["video_from_timestamp"] ?? 0);
1173
- const toTs = Number(row[`videos/${cam}/to_timestamp`] ?? row["video_to_timestamp"] ?? 30);
 
 
 
 
 
 
 
 
 
 
 
 
1174
  const videoPath = `videos/${cam}/chunk-${cIdx.toString().padStart(3, "0")}/file-${fIdx.toString().padStart(3, "0")}.mp4`;
1175
  framesByCamera[cam].push({
1176
  episodeIndex: epIdx,
@@ -1210,7 +1327,10 @@ export async function loadAllEpisodeFrameInfo(
1210
 
1211
  // ─── Cross-episode action variance ──────────────────────────────
1212
 
1213
- export type LowMovementEpisode = { episodeIndex: number; totalMovement: number };
 
 
 
1214
 
1215
  export type AggVelocityStat = {
1216
  name: string;
@@ -1271,10 +1391,16 @@ export async function loadCrossEpisodeActionVariance(
1271
  maxEpisodes = 500,
1272
  numTimeBins = 50,
1273
  ): Promise<CrossEpisodeVarianceData | null> {
1274
- const actionEntry = Object.entries(info.features)
1275
- .find(([key, f]) => key === "action" && f.shape.length === 1);
 
1276
  if (!actionEntry) {
1277
- console.warn("[cross-ep] No action feature found. Available features:", Object.entries(info.features).map(([k, f]) => `${k}(${f.dtype}, shape=${JSON.stringify(f.shape)})`).join(", "));
 
 
 
 
 
1278
  return null;
1279
  }
1280
 
@@ -1286,17 +1412,27 @@ export async function loadCrossEpisodeActionVariance(
1286
  names = Object.values(names)[0];
1287
  }
1288
  const actionNames = Array.isArray(names)
1289
- ? (names as string[]).map(n => `${actionKey}${SERIES_NAME_DELIMITER}${n}`)
1290
- : Array.from({ length: actionDim }, (_, i) => `${actionKey}${SERIES_NAME_DELIMITER}${i}`);
 
 
 
1291
 
1292
  // State feature for alignment computation
1293
- const stateEntry = Object.entries(info.features)
1294
- .find(([key, f]) => key === "observation.state" && f.shape.length === 1);
 
1295
  const stateKey = stateEntry?.[0] ?? null;
1296
  const stateDim = stateEntry?.[1].shape[0] ?? 0;
1297
 
1298
  // Collect episode metadata
1299
- type EpMeta = { index: number; chunkIdx: number; fileIdx: number; from: number; to: number };
 
 
 
 
 
 
1300
  const allEps: EpMeta[] = [];
1301
 
1302
  if (version === "v3.0") {
@@ -1304,7 +1440,9 @@ export async function loadCrossEpisodeActionVariance(
1304
  while (true) {
1305
  const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
1306
  try {
1307
- const buf = await fetchParquetFile(buildVersionedUrl(repoId, version, path));
 
 
1308
  const rows = await readParquetAsObjects(buf, []);
1309
  if (rows.length === 0 && fileIndex > 0) break;
1310
  for (const row of rows) {
@@ -1318,7 +1456,9 @@ export async function loadCrossEpisodeActionVariance(
1318
  });
1319
  }
1320
  fileIndex++;
1321
- } catch { break; }
 
 
1322
  }
1323
  } else {
1324
  for (let i = 0; i < info.total_episodes; i++) {
@@ -1327,17 +1467,24 @@ export async function loadCrossEpisodeActionVariance(
1327
  }
1328
 
1329
  if (allEps.length < 2) {
1330
- console.warn(`[cross-ep] Only ${allEps.length} episode(s) found in metadata, need ≥2`);
 
 
1331
  return null;
1332
  }
1333
- console.log(`[cross-ep] Found ${allEps.length} episodes in metadata, sampling up to ${maxEpisodes}`);
 
 
1334
 
1335
  // Sample episodes evenly
1336
- const sampled = allEps.length <= maxEpisodes
1337
- ? allEps
1338
- : Array.from({ length: maxEpisodes }, (_, i) =>
1339
- allEps[Math.round((i * (allEps.length - 1)) / (maxEpisodes - 1))]
1340
- );
 
 
 
1341
 
1342
  // Load action (and state) data per episode
1343
  const episodeActions: { index: number; actions: number[][] }[] = [];
@@ -1355,9 +1502,14 @@ export async function loadCrossEpisodeActionVariance(
1355
  const ep0 = eps[0];
1356
  const dataPath = `data/chunk-${ep0.chunkIdx.toString().padStart(3, "0")}/file-${ep0.fileIdx.toString().padStart(3, "0")}.parquet`;
1357
  try {
1358
- const buf = await fetchParquetFile(buildVersionedUrl(repoId, version, dataPath));
 
 
1359
  const rows = await readParquetAsObjects(buf, []);
1360
- const fileStart = rows.length > 0 && rows[0].index !== undefined ? Number(rows[0].index) : 0;
 
 
 
1361
 
1362
  for (const ep of eps) {
1363
  const localFrom = Math.max(0, ep.from - fileStart);
@@ -1374,10 +1526,14 @@ export async function loadCrossEpisodeActionVariance(
1374
  }
1375
  if (actions.length > 0) {
1376
  episodeActions.push({ index: ep.index, actions });
1377
- episodeStates.push(stateKey && states.length === actions.length ? states : null);
 
 
1378
  }
1379
  }
1380
- } catch { /* skip file */ }
 
 
1381
  }
1382
  } else {
1383
  const chunkSize = info.chunks_size || 1000;
@@ -1388,7 +1544,9 @@ export async function loadCrossEpisodeActionVariance(
1388
  episode_index: ep.index.toString().padStart(6, "0"),
1389
  });
1390
  try {
1391
- const buf = await fetchParquetFile(buildVersionedUrl(repoId, version, dataPath));
 
 
1392
  const rows = await readParquetAsObjects(buf, []);
1393
  const actions: number[][] = [];
1394
  const states: number[][] = [];
@@ -1411,22 +1569,39 @@ export async function loadCrossEpisodeActionVariance(
1411
  }
1412
  if (actions.length > 0) {
1413
  episodeActions.push({ index: ep.index, actions });
1414
- episodeStates.push(stateKey && states.length === actions.length ? states : null);
 
 
1415
  }
1416
- } catch { /* skip */ }
 
 
1417
  }
1418
  }
1419
 
1420
  if (episodeActions.length < 2) {
1421
- console.warn(`[cross-ep] Only ${episodeActions.length} episode(s) had loadable action data out of ${sampled.length} sampled`);
 
 
1422
  return null;
1423
  }
1424
- console.log(`[cross-ep] Loaded action data for ${episodeActions.length}/${sampled.length} episodes`);
 
 
1425
 
1426
  // Resample each episode to numTimeBins and compute variance
1427
- const timeBins = Array.from({ length: numTimeBins }, (_, i) => i / (numTimeBins - 1));
1428
- const sums = Array.from({ length: numTimeBins }, () => new Float64Array(actionDim));
1429
- const sumsSq = Array.from({ length: numTimeBins }, () => new Float64Array(actionDim));
 
 
 
 
 
 
 
 
 
1430
  const counts = new Uint32Array(numTimeBins);
1431
 
1432
  for (const { actions: epActions } of episodeActions) {
@@ -1448,7 +1623,10 @@ export async function loadCrossEpisodeActionVariance(
1448
  const row: number[] = [];
1449
  const n = counts[b];
1450
  for (let d = 0; d < actionDim; d++) {
1451
- if (n < 2) { row.push(0); continue; }
 
 
 
1452
  const mean = sums[b][d] / n;
1453
  row.push(sumsSq[b][d] / n - mean * mean);
1454
  }
@@ -1456,26 +1634,34 @@ export async function loadCrossEpisodeActionVariance(
1456
  }
1457
 
1458
  // Per-episode average movement per frame: mean L2 norm of frame-to-frame action deltas
1459
- const movementScores: LowMovementEpisode[] = episodeActions.map(({ index, actions: ep }) => {
1460
- if (ep.length < 2) return { episodeIndex: index, totalMovement: 0 };
1461
- let total = 0;
1462
- for (let t = 1; t < ep.length; t++) {
1463
- let sumSq = 0;
1464
- for (let d = 0; d < actionDim; d++) {
1465
- const delta = (ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0);
1466
- sumSq += delta * delta;
 
 
 
1467
  }
1468
- total += Math.sqrt(sumSq);
1469
- }
1470
- const avgPerFrame = total / (ep.length - 1);
1471
- return { episodeIndex: index, totalMovement: Math.round(avgPerFrame * 10000) / 10000 };
1472
- });
 
 
1473
 
1474
  movementScores.sort((a, b) => a.totalMovement - b.totalMovement);
1475
  const lowMovementEpisodes = movementScores.slice(0, 10);
1476
 
1477
  // Aggregated velocity stats: pool deltas from all episodes
1478
- const shortName = (k: string) => { const p = k.split(SERIES_NAME_DELIMITER); return p.length > 1 ? p[p.length - 1] : k; };
 
 
 
1479
 
1480
  const aggVelocity: AggVelocityStat[] = (() => {
1481
  const binCount = 30;
@@ -1486,79 +1672,127 @@ export async function loadCrossEpisodeActionVariance(
1486
  deltas.push((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0));
1487
  }
1488
  }
1489
- if (deltas.length === 0) return { name: shortName(actionNames[d]), std: 0, maxAbs: 0, bins: [], lo: 0, hi: 0 };
1490
- let sum = 0, maxAbs = 0, lo = Infinity, hi = -Infinity;
1491
- for (const v of deltas) { sum += v; const a = Math.abs(v); if (a > maxAbs) maxAbs = a; if (v < lo) lo = v; if (v > hi) hi = v; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1492
  const mean = sum / deltas.length;
1493
- let varSum = 0; for (const v of deltas) varSum += (v - mean) ** 2;
 
1494
  const std = Math.sqrt(varSum / deltas.length);
1495
  const range = hi - lo || 1;
1496
  const binW = range / binCount;
1497
  const bins = new Array(binCount).fill(0);
1498
- for (const v of deltas) { let b = Math.floor((v - lo) / binW); if (b >= binCount) b = binCount - 1; bins[b]++; }
 
 
 
 
1499
  return { name: shortName(actionNames[d]), std, maxAbs, bins, lo, hi };
1500
  });
1501
  })();
1502
 
1503
  // Aggregated autocorrelation: average per-episode ACFs
1504
  const aggAutocorrelation: AggAutocorrelation | null = (() => {
1505
- const maxLag = Math.min(100, Math.floor(
1506
- episodeActions.reduce((min, e) => Math.min(min, e.actions.length), Infinity) / 2
1507
- ));
 
 
 
 
 
 
1508
  if (maxLag < 2) return null;
1509
 
1510
- const avgAcf: number[][] = Array.from({ length: actionDim }, () => new Array(maxLag).fill(0));
 
 
1511
  let epCount = 0;
1512
 
1513
  for (const { actions: ep } of episodeActions) {
1514
  if (ep.length < maxLag * 2) continue;
1515
  epCount++;
1516
  for (let d = 0; d < actionDim; d++) {
1517
- const vals = ep.map(row => row[d] ?? 0);
1518
  const n = vals.length;
1519
  const m = vals.reduce((a, b) => a + b, 0) / n;
1520
- const centered = vals.map(v => v - m);
1521
  const vari = centered.reduce((a, v) => a + v * v, 0);
1522
  if (vari === 0) continue;
1523
  for (let lag = 1; lag <= maxLag; lag++) {
1524
  let s = 0;
1525
- for (let t = 0; t < n - lag; t++) s += centered[t] * centered[t + lag];
 
1526
  avgAcf[d][lag - 1] += s / vari;
1527
  }
1528
  }
1529
  }
1530
 
1531
  if (epCount === 0) return null;
1532
- for (let d = 0; d < actionDim; d++) for (let l = 0; l < maxLag; l++) avgAcf[d][l] /= epCount;
 
1533
 
1534
  const shortKeys = actionNames.map(shortName);
1535
  const chartData = Array.from({ length: maxLag }, (_, lag) => {
1536
- const row: Record<string, number> = { lag: lag + 1, time: (lag + 1) / fps };
1537
- shortKeys.forEach((k, d) => { row[k] = avgAcf[d][lag]; });
 
 
 
 
 
1538
  return row;
1539
  });
1540
 
1541
  // Suggested chunk: median lag where ACF drops below 0.5
1542
- const lags = avgAcf.map(acf => { const i = acf.findIndex(v => v < 0.5); return i >= 0 ? i + 1 : null; }).filter(Boolean) as number[];
1543
- const suggestedChunk = lags.length > 0 ? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)] : null;
 
 
 
 
 
 
 
 
1544
 
1545
  return { chartData, suggestedChunk, shortKeys };
1546
  })();
1547
 
1548
  // Per-episode jerkiness: mean |Δa| across all dimensions
1549
- const jerkyEpisodes: JerkyEpisode[] = episodeActions.map(({ index, actions: ep }) => {
1550
- let sum = 0, count = 0;
1551
- for (let t = 1; t < ep.length; t++) {
1552
- for (let d = 0; d < actionDim; d++) {
1553
- sum += Math.abs((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0));
1554
- count++;
 
 
 
1555
  }
1556
- }
1557
- return { episodeIndex: index, meanAbsDelta: count > 0 ? sum / count : 0 };
1558
- }).sort((a, b) => b.meanAbsDelta - a.meanAbsDelta);
1559
 
1560
  // Speed distribution: all episode movement scores (not just lowest 10)
1561
- const speedDistribution: SpeedDistEntry[] = movementScores.map(s => ({
1562
  episodeIndex: s.episodeIndex,
1563
  speed: s.totalMovement,
1564
  }));
@@ -1568,16 +1802,20 @@ export async function loadCrossEpisodeActionVariance(
1568
  if (!stateKey || stateDim === 0) return null;
1569
 
1570
  let sNms: unknown = stateEntry![1].names;
1571
- while (typeof sNms === "object" && sNms !== null && !Array.isArray(sNms)) sNms = Object.values(sNms)[0];
 
1572
  const stateNames = Array.isArray(sNms)
1573
  ? (sNms as string[])
1574
  : Array.from({ length: stateDim }, (_, i) => `${i}`);
1575
- const actionSuffixes = actionNames.map(n => { const p = n.split(SERIES_NAME_DELIMITER); return p[p.length - 1]; });
 
 
 
1576
 
1577
  // Match pairs by suffix, fall back to index
1578
  const pairs: [number, number][] = [];
1579
  for (let ai = 0; ai < actionDim; ai++) {
1580
- const si = stateNames.findIndex(s => s === actionSuffixes[ai]);
1581
  if (si >= 0) pairs.push([ai, si]);
1582
  }
1583
  if (pairs.length === 0) {
@@ -1600,64 +1838,113 @@ export async function loadCrossEpisodeActionVariance(
1600
 
1601
  for (let pi = 0; pi < pairs.length; pi++) {
1602
  const [ai, si] = pairs[pi];
1603
- const aVals = actions.slice(0, n).map(r => r[ai] ?? 0);
1604
- const sDeltas = Array.from({ length: n - 1 }, (_, t) => (states[t + 1][si] ?? 0) - (states[t][si] ?? 0));
 
 
 
1605
  const effN = Math.min(aVals.length, sDeltas.length);
1606
  const aM = aVals.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
1607
  const sM = sDeltas.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
1608
 
1609
  for (let li = 0; li < numLags; li++) {
1610
  const lag = -maxLag + li;
1611
- let sum = 0, aV = 0, sV = 0;
 
 
1612
  for (let t = 0; t < effN; t++) {
1613
  const sIdx = t + lag;
1614
  if (sIdx < 0 || sIdx >= sDeltas.length) continue;
1615
- const a = aVals[t] - aM, s = sDeltas[sIdx] - sM;
1616
- sum += a * s; aV += a * a; sV += s * s;
 
 
 
1617
  }
1618
  const d = Math.sqrt(aV * sV);
1619
- if (d > 0) { corrSums[pi][li] += sum / d; corrCounts[pi][li]++; }
 
 
 
1620
  }
1621
  }
1622
  }
1623
 
1624
  const avgCorrs = pairs.map((_, pi) =>
1625
  Array.from({ length: numLags }, (_, li) =>
1626
- corrCounts[pi][li] > 0 ? corrSums[pi][li] / corrCounts[pi][li] : 0
1627
- )
1628
  );
1629
 
1630
  const ccData = Array.from({ length: numLags }, (_, li) => {
1631
  const lag = -maxLag + li;
1632
- const vals = avgCorrs.map(pc => pc[li]);
1633
- return { lag, max: Math.max(...vals), mean: vals.reduce((a, b) => a + b, 0) / vals.length, min: Math.min(...vals) };
 
 
 
 
 
1634
  });
1635
 
1636
- let meanPeakLag = 0, meanPeakCorr = -Infinity;
1637
- let maxPeakLag = 0, maxPeakCorr = -Infinity;
1638
- let minPeakLag = 0, minPeakCorr = -Infinity;
 
 
 
1639
  for (const row of ccData) {
1640
- if (row.max > maxPeakCorr) { maxPeakCorr = row.max; maxPeakLag = row.lag; }
1641
- if (row.mean > meanPeakCorr) { meanPeakCorr = row.mean; meanPeakLag = row.lag; }
1642
- if (row.min > minPeakCorr) { minPeakCorr = row.min; minPeakLag = row.lag; }
 
 
 
 
 
 
 
 
 
1643
  }
1644
 
1645
- const perPairPeakLags = avgCorrs.map(pc => {
1646
- let best = -Infinity, bestLag = 0;
1647
- for (let li = 0; li < pc.length; li++) { if (pc[li] > best) { best = pc[li]; bestLag = -maxLag + li; } }
 
 
 
 
 
 
1648
  return bestLag;
1649
  });
1650
 
1651
  return {
1652
- ccData, meanPeakLag, meanPeakCorr, maxPeakLag, maxPeakCorr, minPeakLag, minPeakCorr,
1653
- lagRangeMin: Math.min(...perPairPeakLags), lagRangeMax: Math.max(...perPairPeakLags), numPairs: pairs.length,
 
 
 
 
 
 
 
 
1654
  };
1655
  })();
1656
 
1657
  return {
1658
- actionNames, timeBins, variance, numEpisodes: episodeActions.length,
1659
- lowMovementEpisodes, aggVelocity, aggAutocorrelation,
1660
- speedDistribution, jerkyEpisodes, aggAlignment,
 
 
 
 
 
 
 
1661
  };
1662
  }
1663
 
@@ -1668,8 +1955,17 @@ export async function loadEpisodeFlatChartData(
1668
  info: DatasetMetadata,
1669
  episodeId: number,
1670
  ): Promise<Record<string, number>[]> {
1671
- const episodeMetadata = await loadEpisodeMetadataV3Simple(repoId, version, episodeId);
1672
- const { flatChartData } = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
 
 
 
 
 
 
 
 
 
1673
  return flatChartData;
1674
  }
1675
 
 
5
  readParquetAsObjects,
6
  } from "@/utils/parquetUtils";
7
  import { pick } from "@/utils/pick";
8
+ import {
9
+ getDatasetVersionAndInfo,
10
+ buildVersionedUrl,
11
+ } from "@/utils/versionUtils";
12
  import { PADDING, CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants";
13
  import {
14
  processChartDataGroups,
 
124
  }
125
 
126
  console.time(`[perf] getEpisodeData (${version})`);
127
+ const result =
128
+ version === "v3.0"
129
+ ? await getEpisodeDataV3(repoId, version, info, episodeId)
130
+ : await getEpisodeDataV2(repoId, version, info, episodeId);
131
  console.timeEnd(`[perf] getEpisodeData (${version})`);
132
 
133
  // Extract camera resolutions from features
 
140
  robot_type: rawInfo.robot_type ?? null,
141
  codebase_version: rawInfo.codebase_version,
142
  total_tasks: rawInfo.total_tasks ?? 0,
143
+ dataset_size_mb:
144
+ Math.round(
145
+ ((rawInfo.data_files_size_in_mb ?? 0) +
146
+ (rawInfo.video_files_size_in_mb ?? 0)) *
147
+ 10,
148
+ ) / 10,
149
  cameras,
150
  };
151
 
 
166
  try {
167
  const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId);
168
  const info = rawInfo as unknown as DatasetMetadata;
169
+
170
  const totalEpisodes = info.total_episodes;
171
  const adjacentVideos: AdjacentEpisodeVideos[] = [];
172
+
173
  // Calculate adjacent episode IDs
174
  for (let offset = -radius; offset <= radius; offset++) {
175
  if (offset === 0) continue; // Skip current episode
176
+
177
  const episodeId = currentEpisodeId + offset;
178
  if (episodeId >= 0 && episodeId < totalEpisodes) {
179
  try {
180
  let videosInfo: VideoInfo[] = [];
181
+
182
  if (version === "v3.0") {
183
  const episodeMetadata = await loadEpisodeMetadataV3Simple(
184
  repoId,
 
194
  } else {
195
  // For v2.x, use simpler video info extraction
196
  if (info.video_path) {
197
+ const episode_chunk = Math.floor(0 / 1000);
198
+ videosInfo = Object.entries(info.features)
199
+ .filter(([, value]) => value.dtype === "video")
200
+ .map(([key]) => {
201
  const videoPath = formatStringWithVars(info.video_path!, {
202
+ video_key: key,
203
  episode_chunk: episode_chunk
204
  .toString()
205
  .padStart(PADDING.CHUNK_INDEX, "0"),
206
  episode_index: episodeId
207
  .toString()
208
  .padStart(PADDING.EPISODE_INDEX, "0"),
209
+ });
210
+ return {
211
+ filename: key,
212
+ url: buildVersionedUrl(repoId, version, videoPath),
213
+ };
214
  });
 
 
 
 
 
215
  }
216
  }
217
+
218
  adjacentVideos.push({ episodeId, videosInfo });
219
+ } catch {
220
+ // Skip failed episodes silently
221
+ }
222
  }
223
  }
224
+
225
  return adjacentVideos;
226
  } catch {
227
  // Return empty array on error
 
262
  .map((x) => parseInt(x.trim(), 10))
263
  .filter((x) => !isNaN(x));
264
 
265
+ // Videos information
266
  const videosInfo =
267
  info.video_path !== null
268
  ? Object.entries(info.features)
269
+ .filter(([, value]) => value.dtype === "video")
270
+ .map(([key]) => {
271
  const videoPath = formatStringWithVars(info.video_path!, {
272
+ video_key: key,
273
  episode_chunk: episode_chunk
274
  .toString()
275
  .padStart(PADDING.CHUNK_INDEX, "0"),
276
  episode_index: episodeId
277
  .toString()
278
  .padStart(PADDING.EPISODE_INDEX, "0"),
279
+ });
280
+ return {
281
+ filename: key,
282
+ url: buildVersionedUrl(repoId, version, videoPath),
283
+ };
284
  })
285
  : [];
286
 
 
306
  return {
307
  key,
308
  value: Array.isArray(column_names)
309
+ ? column_names.map(
310
+ (name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
311
+ )
312
  : Array.from(
313
  { length: columnNames.find((c) => c.key === key)?.length ?? 1 },
314
  (_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
 
329
 
330
  const arrayBuffer = await fetchParquetFile(parquetUrl);
331
  const allData = await readParquetAsObjects(arrayBuffer, []);
332
+
333
  // Extract task from language_instruction fields, task field, or tasks.jsonl
334
  let task: string | undefined;
335
+
336
  if (allData.length > 0) {
337
  const firstRow = allData[0];
338
  const languageInstructions: string[] = [];
339
+
340
+ if (typeof firstRow.language_instruction === "string") {
341
  languageInstructions.push(firstRow.language_instruction);
342
  }
343
+
344
  let instructionNum = 2;
345
+ while (
346
+ typeof firstRow[`language_instruction_${instructionNum}`] === "string"
347
+ ) {
348
+ languageInstructions.push(
349
+ firstRow[`language_instruction_${instructionNum}`] as string,
350
+ );
351
  instructionNum++;
352
  }
353
+
354
  if (languageInstructions.length > 0) {
355
+ task = languageInstructions.join("\n");
356
  }
357
  }
358
+
359
+ if (!task && allData.length > 0 && typeof allData[0].task === "string") {
360
  task = allData[0].task;
361
  }
362
+
363
  if (!task && allData.length > 0) {
364
  try {
365
  const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
366
  const tasksResponse = await fetch(tasksUrl);
367
+
368
  if (tasksResponse.ok) {
369
  const tasksText = await tasksResponse.text();
370
  const tasksData = tasksText
371
  .split("\n")
372
  .filter((line) => line.trim())
373
  .map((line) => JSON.parse(line));
374
+
375
  if (tasksData && tasksData.length > 0) {
376
  const taskIndex = allData[0].task_index;
377
+ const taskIndexNum =
378
+ typeof taskIndex === "bigint" ? Number(taskIndex) : taskIndex;
379
+ const taskData = tasksData.find(
380
+ (t: Record<string, unknown>) => t.task_index === taskIndexNum,
381
+ );
382
  if (taskData) {
383
  task = taskData.task;
384
  }
 
388
  // No tasks metadata file for this v2.x dataset
389
  }
390
  }
391
+
392
  // Build chart data from already-parsed allData (no second parquet parse)
393
  const seriesNames = [
394
  "timestamp",
 
403
  if (Array.isArray(rawVal)) {
404
  rawVal.forEach((v: unknown, i: number) => {
405
  if (i < col.value.length) obj[col.value[i]] = Number(v);
406
+ });
407
  } else if (rawVal !== undefined) {
408
  obj[col.value[0]] = Number(rawVal);
409
  }
 
476
  version,
477
  episodeId,
478
  );
479
+
480
  // Create video info with segmentation using the metadata
481
  const videosInfo = extractVideoInfoV3WithSegmentation(
482
  repoId,
 
486
  );
487
 
488
  // Load episode data for charts
489
+ const { chartDataGroups, flatChartData, ignoredColumns, task } =
490
+ await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
491
 
492
+ const duration = episodeMetadata.length
493
+ ? episodeMetadata.length / info.fps
494
+ : episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp;
495
 
496
  return {
497
  datasetInfo,
 
512
  version: string,
513
  info: DatasetMetadata,
514
  episodeMetadata: EpisodeMetadataV3,
515
+ ): Promise<{
516
+ chartDataGroups: ChartRow[][];
517
+ flatChartData: Record<string, number>[];
518
+ ignoredColumns: string[];
519
+ task?: string;
520
+ }> {
521
  // Build data file path using chunk and file indices
522
  const dataChunkIndex = bigIntToNumber(episodeMetadata.data_chunk_index, 0);
523
  const dataFileIndex = bigIntToNumber(episodeMetadata.data_file_index, 0);
524
  const dataPath = buildV3DataPath(dataChunkIndex, dataFileIndex);
525
+
526
  try {
527
  const dataUrl = buildVersionedUrl(repoId, version, dataPath);
528
  const arrayBuffer = await fetchParquetFile(dataUrl);
529
  const fullData = await readParquetAsObjects(arrayBuffer, []);
530
+
531
  // Extract the episode-specific data slice
532
  const fromIndex = bigIntToNumber(episodeMetadata.dataset_from_index, 0);
533
+ const toIndex = bigIntToNumber(
534
+ episodeMetadata.dataset_to_index,
535
+ fullData.length,
536
+ );
537
+
538
  // Find the starting index of this parquet file by checking the first row's index
539
  // This handles the case where episodes are split across multiple parquet files
540
  let fileStartIndex = 0;
541
  if (fullData.length > 0 && fullData[0].index !== undefined) {
542
  fileStartIndex = Number(fullData[0].index);
543
  }
544
+
545
  // Adjust indices to be relative to this file's starting position
546
  const localFromIndex = Math.max(0, fromIndex - fileStartIndex);
547
  const localToIndex = Math.min(fullData.length, toIndex - fileStartIndex);
548
+
549
  const episodeData = fullData.slice(localFromIndex, localToIndex);
550
+
551
  if (episodeData.length === 0) {
552
+ return {
553
+ chartDataGroups: [],
554
+ flatChartData: [],
555
+ ignoredColumns: [],
556
+ task: undefined,
557
+ };
558
  }
559
+
560
  // Convert to the same format as v2.x for compatibility with existing chart code
561
+ const { chartDataGroups, flatChartData, ignoredColumns } =
562
+ processEpisodeDataForCharts(episodeData, info, episodeMetadata);
563
+
564
  // First check for language_instruction fields in the data (preferred)
565
  let task: string | undefined;
566
  if (episodeData.length > 0) {
567
  const languageInstructions: string[] = [];
568
+
569
  const extractInstructions = (row: Record<string, unknown>) => {
570
+ if (typeof row.language_instruction === "string") {
571
  languageInstructions.push(row.language_instruction);
572
  }
573
  let num = 2;
574
+ while (typeof row[`language_instruction_${num}`] === "string") {
575
+ languageInstructions.push(
576
+ row[`language_instruction_${num}`] as string,
577
+ );
578
  num++;
579
  }
580
  };
581
 
582
  extractInstructions(episodeData[0]);
583
+
584
  // If no instructions in first row, check middle and last rows
585
  if (languageInstructions.length === 0 && episodeData.length > 1) {
586
+ for (const idx of [
587
+ Math.floor(episodeData.length / 2),
588
+ episodeData.length - 1,
589
+ ]) {
590
  extractInstructions(episodeData[idx]);
591
  if (languageInstructions.length > 0) break;
592
  }
593
  }
594
+
595
  if (languageInstructions.length > 0) {
596
+ task = languageInstructions.join("\n");
597
  }
598
  }
599
+
600
  // Fall back to tasks metadata parquet
601
  if (!task && episodeData.length > 0) {
602
  try {
603
+ const tasksUrl = buildVersionedUrl(
604
+ repoId,
605
+ version,
606
+ "meta/tasks.parquet",
607
+ );
608
  const tasksArrayBuffer = await fetchParquetFile(tasksUrl);
609
  const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
610
+
611
  if (tasksData.length > 0) {
612
  const taskIndexNum = bigIntToNumber(episodeData[0].task_index, -1);
613
 
614
  if (taskIndexNum >= 0 && taskIndexNum < tasksData.length) {
615
  const taskData = tasksData[taskIndexNum];
616
  const rawTask = taskData.__index_level_0__ ?? taskData.task;
617
+ task = typeof rawTask === "string" ? rawTask : undefined;
618
  }
619
  }
620
  } catch {
621
  // Could not load tasks metadata
622
  }
623
  }
624
+
625
  return { chartDataGroups, flatChartData, ignoredColumns, task };
626
  } catch {
627
+ return {
628
+ chartDataGroups: [],
629
+ flatChartData: [],
630
+ ignoredColumns: [],
631
+ task: undefined,
632
+ };
633
  }
634
  }
635
 
 
638
  episodeData: Record<string, unknown>[],
639
  info: DatasetMetadata,
640
  episodeMetadata?: EpisodeMetadataV3,
641
+ ): {
642
+ chartDataGroups: ChartRow[][];
643
+ flatChartData: Record<string, number>[];
644
+ ignoredColumns: string[];
645
+ } {
646
  // Convert parquet data to chart format
647
  let seriesNames: string[] = [];
648
+
649
  // Dynamically create a mapping from numeric indices to feature names based on actual dataset features
650
  const v3IndexToFeatureMap: Record<string, string> = {};
651
+
652
  // Build mapping based on what features actually exist in the dataset
653
  const featureKeys = Object.keys(info.features);
654
+
655
  // Common feature order for v3.0 datasets (but only include if they exist)
656
  const expectedFeatureOrder = [
657
  "observation.state",
 
664
  "index",
665
  "task_index",
666
  ];
667
+
668
  // Map indices to features that actually exist
669
  let currentIndex = 0;
670
  expectedFeatureOrder.forEach((feature) => {
 
673
  currentIndex++;
674
  }
675
  });
676
+
677
  // Columns to exclude from charts (note: 'task' is intentionally not excluded as we want to access it)
678
  const excludedColumns = EXCLUDED_COLUMNS.V3 as readonly string[];
679
 
680
  // Create columns structure similar to V2.1 for proper hierarchical naming
681
  const columns: ColumnDef[] = Object.entries(info.features)
682
+ .filter(
683
+ ([key, value]) =>
684
+ ["float32", "int32"].includes(value.dtype) &&
685
+ value.shape.length === 1 &&
686
+ !excludedColumns.includes(key),
687
  )
688
  .map(([key, feature]) => {
689
  let column_names: unknown = feature.names;
 
694
  return {
695
  key,
696
  value: Array.isArray(column_names)
697
+ ? column_names.map(
698
+ (name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
699
+ )
700
  : Array.from(
701
  { length: feature.shape[0] || 1 },
702
  (_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
 
708
  if (episodeData.length > 0) {
709
  const firstRow = episodeData[0];
710
  const allKeys: string[] = [];
711
+
712
  Object.entries(firstRow || {}).forEach(([key, value]) => {
713
  if (key === "timestamp") return; // Skip timestamp, we'll add it separately
714
+
715
  // Map numeric key to feature name if available
716
  const featureName = v3IndexToFeatureMap[key] || key;
717
+
718
  // Skip if feature doesn't exist in dataset
719
  if (!info.features[featureName]) return;
720
+
721
  // Skip excluded columns
722
  if (excludedColumns.includes(featureName)) return;
723
+
724
  // Find the matching column definition to get proper names
725
  const columnDef = columns.find((col) => col.key === featureName);
726
  if (columnDef && Array.isArray(value) && value.length > 0) {
 
738
  allKeys.push(featureName);
739
  }
740
  });
741
+
742
  seriesNames = ["timestamp", ...allKeys];
743
  } else {
744
  // Fallback to column-based approach like V2.1
 
747
 
748
  const chartData = episodeData.map((row, index) => {
749
  const obj: Record<string, number> = {};
750
+
751
  // Add timestamp aligned with video timing
752
  // For v3.0, we need to map the episode data index to the actual video duration
753
  let videoDuration = episodeData.length; // Fallback to data length
 
759
  }
760
  obj["timestamp"] =
761
  (index / Math.max(episodeData.length - 1, 1)) * videoDuration;
762
+
763
  // Add all data columns using hierarchical naming
764
  if (row && typeof row === "object") {
765
  Object.entries(row).forEach(([key, value]) => {
 
767
  // Timestamp is already handled above
768
  return;
769
  }
770
+
771
  // Map numeric key to feature name if available
772
  const featureName = v3IndexToFeatureMap[key] || key;
773
+
774
  // Skip if feature doesn't exist in dataset
775
  if (!info.features[featureName]) return;
776
+
777
  // Skip excluded columns
778
  if (excludedColumns.includes(featureName)) return;
779
+
780
  // Find the matching column definition to get proper series names
781
  const columnDef = columns.find((col) => col.key === featureName);
782
+
783
  if (Array.isArray(value) && columnDef) {
784
  // For array values like observation.state and action, use proper hierarchical naming
785
  value.forEach((val, idx) => {
 
798
  }
799
  });
800
  }
801
+
802
  return obj;
803
  });
804
 
 
848
  const cameraSpecificKeys = Object.keys(episodeMetadata).filter((key) =>
849
  key.startsWith(`videos/${videoKey}/`),
850
  );
851
+
852
+ let chunkIndex: number,
853
+ fileIndex: number,
854
+ segmentStart: number,
855
+ segmentEnd: number;
856
+
857
+ const toNum = (v: string | number): number =>
858
+ typeof v === "string" ? parseFloat(v) || 0 : v;
859
 
860
  if (cameraSpecificKeys.length > 0) {
861
  chunkIndex = toNum(episodeMetadata[`videos/${videoKey}/chunk_index`]);
862
  fileIndex = toNum(episodeMetadata[`videos/${videoKey}/file_index`]);
863
+ segmentStart =
864
+ toNum(episodeMetadata[`videos/${videoKey}/from_timestamp`]) || 0;
865
+ segmentEnd =
866
+ toNum(episodeMetadata[`videos/${videoKey}/to_timestamp`]) || 30;
867
  } else {
868
  chunkIndex = episodeMetadata.video_chunk_index || 0;
869
  fileIndex = episodeMetadata.video_file_index || 0;
870
  segmentStart = episodeMetadata.video_from_timestamp || 0;
871
  segmentEnd = episodeMetadata.video_to_timestamp || 30;
872
  }
873
+
874
  // Convert BigInt to number for timestamps
875
  const startNum = bigIntToNumber(segmentStart);
876
  const endNum = bigIntToNumber(segmentEnd);
 
881
  bigIntToNumber(fileIndex, 0),
882
  );
883
  const fullUrl = buildVersionedUrl(repoId, version, videoPath);
884
+
885
  return {
886
  filename: videoKey,
887
  url: fullUrl,
 
904
  ): Promise<EpisodeMetadataV3> {
905
  // Pattern: meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet
906
  // Most datasets have all episodes in chunk-000/file-000, but episodes can be split across files
907
+
908
  let episodeRow = null;
909
  let fileIndex = 0;
910
  const chunkIndex = 0; // Episodes are typically in chunk-000
911
+
912
  // Try loading episode metadata files until we find the episode
913
  while (!episodeRow) {
914
  const episodesMetadataPath = buildV3EpisodesMetadataPath(
 
924
  try {
925
  const arrayBuffer = await fetchParquetFile(episodesMetadataUrl);
926
  const episodesData = await readParquetAsObjects(arrayBuffer, []);
927
+
928
  if (episodesData.length === 0) {
929
  // Empty file, try next one
930
  fileIndex++;
931
  continue;
932
  }
933
+
934
  // Find the row for the requested episode by episode_index
935
  for (const row of episodesData) {
936
  const parsedRow = parseEpisodeRowSimple(row);
937
+
938
  if (parsedRow.episode_index === episodeId) {
939
  episodeRow = row;
940
  break;
941
  }
942
  }
943
+
944
  if (!episodeRow) {
945
  // Not in this file, try the next one
946
  fileIndex++;
 
952
  );
953
  }
954
  }
955
+
956
  // Convert the row to a usable format
957
  return parseEpisodeRowSimple(episodeRow);
958
  }
959
 
960
  // Simple parser for episode row - focuses on key fields for episodes
961
+ function parseEpisodeRowSimple(
962
+ row: Record<string, unknown>,
963
+ ): EpisodeMetadataV3 {
964
  // v3.0 uses named keys in the episode metadata
965
  if (row && typeof row === "object") {
966
  // Check if this is v3.0 format with named keys
 
968
  // v3.0 format - use named keys
969
  // Convert BigInt values to numbers
970
  const toBigIntSafe = (value: unknown): number => {
971
+ if (typeof value === "bigint") return Number(value);
972
+ if (typeof value === "number") return value;
973
+ if (typeof value === "string") return parseInt(value) || 0;
974
  return 0;
975
  };
976
+
977
  const toNumSafe = (value: unknown): number => {
978
+ if (typeof value === "number") return value;
979
+ if (typeof value === "bigint") return Number(value);
980
+ if (typeof value === "string") return parseFloat(value) || 0;
981
  return 0;
982
  };
983
 
984
  // Handle video metadata - look for video-specific keys
985
+ const videoKeys = Object.keys(row).filter(
986
+ (key) => key.includes("videos/") && key.includes("/chunk_index"),
987
+ );
988
+ let videoChunkIndex = 0,
989
+ videoFileIndex = 0,
990
+ videoFromTs = 0,
991
+ videoToTs = 30;
992
  if (videoKeys.length > 0) {
993
+ const videoBaseName = videoKeys[0].replace("/chunk_index", "");
994
  videoChunkIndex = toBigIntSafe(row[`${videoBaseName}/chunk_index`]);
995
  videoFileIndex = toBigIntSafe(row[`${videoBaseName}/file_index`]);
996
  videoFromTs = toNumSafe(row[`${videoBaseName}/from_timestamp`]);
 
998
  }
999
 
1000
  const episodeData: EpisodeMetadataV3 = {
1001
+ episode_index: toBigIntSafe(row["episode_index"]),
1002
+ data_chunk_index: toBigIntSafe(row["data/chunk_index"]),
1003
+ data_file_index: toBigIntSafe(row["data/file_index"]),
1004
+ dataset_from_index: toBigIntSafe(row["dataset_from_index"]),
1005
+ dataset_to_index: toBigIntSafe(row["dataset_to_index"]),
1006
+ length: toBigIntSafe(row["length"]),
1007
  video_chunk_index: videoChunkIndex,
1008
  video_file_index: videoFileIndex,
1009
  video_from_timestamp: videoFromTs,
1010
  video_to_timestamp: videoToTs,
1011
  };
1012
+
1013
  // Store per-camera metadata for extractVideoInfoV3WithSegmentation
1014
+ Object.keys(row).forEach((key) => {
1015
+ if (key.startsWith("videos/")) {
1016
  const val = row[key];
1017
+ episodeData[key] =
1018
+ typeof val === "bigint"
1019
+ ? Number(val)
1020
+ : typeof val === "number" || typeof val === "string"
1021
+ ? val
1022
+ : 0;
1023
  }
1024
  });
1025
+
1026
  return episodeData as EpisodeMetadataV3;
1027
  } else {
1028
  // Fallback to numeric keys for compatibility
1029
  const toNum = (v: unknown, fallback = 0): number =>
1030
+ typeof v === "number"
1031
+ ? v
1032
+ : typeof v === "bigint"
1033
+ ? Number(v)
1034
+ : fallback;
1035
  return {
1036
+ episode_index: toNum(row["0"]),
1037
+ data_chunk_index: toNum(row["1"]),
1038
+ data_file_index: toNum(row["2"]),
1039
+ dataset_from_index: toNum(row["3"]),
1040
+ dataset_to_index: toNum(row["4"]),
1041
+ video_chunk_index: toNum(row["5"]),
1042
+ video_file_index: toNum(row["6"]),
1043
+ video_from_timestamp: toNum(row["7"]),
1044
+ video_to_timestamp: toNum(row["8"], 30),
1045
+ length: toNum(row["9"], 30),
1046
  };
1047
  }
1048
  }
1049
+
1050
  // Fallback if parsing fails
1051
  const fallback = {
1052
  episode_index: 0,
 
1060
  video_to_timestamp: 30,
1061
  length: 30,
1062
  };
1063
+
1064
  return fallback;
1065
  }
1066
 
 
 
1067
  // ─── Stats computation ───────────────────────────────────────────
1068
 
1069
  /**
1070
  * Compute per-column min/max values from the current episode's chart data.
1071
  */
1072
+ export function computeColumnMinMax(
1073
+ chartDataGroups: ChartRow[][],
1074
+ ): ColumnMinMax[] {
1075
  const stats: Record<string, { min: number; max: number }> = {};
1076
 
1077
  for (const group of chartDataGroups) {
 
1133
  if (rows.length === 0 && fileIndex > 0) break;
1134
  for (const row of rows) {
1135
  const parsed = parseEpisodeRowSimple(row);
1136
+ allEpisodes.push({
1137
+ index: parsed.episode_index,
1138
+ length: parsed.length,
1139
+ });
1140
  }
1141
  fileIndex++;
1142
  } catch {
 
1152
  lengthSeconds: Math.round((ep.length / fps) * 100) / 100,
1153
  }));
1154
 
1155
+ const sortedByLength = [...withSeconds].sort(
1156
+ (a, b) => a.lengthSeconds - b.lengthSeconds,
1157
+ );
1158
  const shortestEpisodes = sortedByLength.slice(0, 5);
1159
  const longestEpisodes = sortedByLength.slice(-5).reverse();
1160
 
 
1164
 
1165
  const sorted = [...lengths].sort((a, b) => a - b);
1166
  const mid = Math.floor(sorted.length / 2);
1167
+ const median =
1168
+ sorted.length % 2 === 0
1169
+ ? Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 100) / 100
1170
+ : sorted[mid];
1171
 
1172
+ const variance =
1173
+ lengths.reduce((acc, l) => acc + (l - mean) ** 2, 0) / lengths.length;
1174
  const std = Math.round(Math.sqrt(variance) * 100) / 100;
1175
 
1176
  // Build histogram
 
1179
 
1180
  if (histMax === histMin) {
1181
  return {
1182
+ shortestEpisodes,
1183
+ longestEpisodes,
1184
+ allEpisodeLengths: withSeconds,
1185
+ meanEpisodeLength: mean,
1186
+ medianEpisodeLength: median,
1187
+ stdEpisodeLength: std,
1188
+ episodeLengthHistogram: [
1189
+ { binLabel: `${histMin.toFixed(1)}s`, count: lengths.length },
1190
+ ],
1191
  };
1192
  }
1193
 
1194
  const p1 = sorted[Math.floor(sorted.length * 0.01)];
1195
  const p99 = sorted[Math.ceil(sorted.length * 0.99) - 1];
1196
+ const range = p99 - p1 || 1;
1197
 
1198
+ const targetBins = Math.max(
1199
+ 10,
1200
+ Math.min(50, Math.ceil(Math.log2(lengths.length) + 1)),
1201
+ );
1202
  const rawBinWidth = range / targetBins;
1203
  const magnitude = Math.pow(10, Math.floor(Math.log10(rawBinWidth)));
1204
  const niceSteps = [1, 2, 2.5, 5, 10];
1205
+ const niceBinWidth =
1206
+ niceSteps.map((s) => s * magnitude).find((w) => w >= rawBinWidth) ??
1207
+ rawBinWidth;
1208
 
1209
  const niceMin = Math.floor(p1 / niceBinWidth) * niceBinWidth;
1210
  const niceMax = Math.ceil(p99 / niceBinWidth) * niceBinWidth;
1211
+ const actualBinCount = Math.max(
1212
+ 1,
1213
+ Math.round((niceMax - niceMin) / niceBinWidth),
1214
+ );
1215
  const bins = Array.from({ length: actualBinCount }, () => 0);
1216
 
1217
  for (const len of lengths) {
 
1228
  });
1229
 
1230
  return {
1231
+ shortestEpisodes,
1232
+ longestEpisodes,
1233
+ allEpisodeLengths: withSeconds,
1234
+ meanEpisodeLength: mean,
1235
+ medianEpisodeLength: median,
1236
+ stdEpisodeLength: std,
1237
  episodeLengthHistogram: histogram,
1238
  };
1239
  } catch {
 
1250
  version: string,
1251
  info: DatasetMetadata,
1252
  ): Promise<EpisodeFramesData> {
1253
+ const videoFeatures = Object.entries(info.features).filter(
1254
+ ([, f]) => f.dtype === "video",
1255
+ );
1256
  if (videoFeatures.length === 0) return { cameras: [], framesByCamera: {} };
1257
 
1258
  const cameras = videoFeatures.map(([key]) => key);
 
1264
  while (true) {
1265
  const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
1266
  try {
1267
+ const buf = await fetchParquetFile(
1268
+ buildVersionedUrl(repoId, version, path),
1269
+ );
1270
  const rows = await readParquetAsObjects(buf, []);
1271
  if (rows.length === 0 && fileIndex > 0) break;
1272
  for (const row of rows) {
1273
  const epIdx = Number(row["episode_index"] ?? 0);
1274
  for (const cam of cameras) {
1275
+ const cIdx = Number(
1276
+ row[`videos/${cam}/chunk_index`] ?? row["video_chunk_index"] ?? 0,
1277
+ );
1278
+ const fIdx = Number(
1279
+ row[`videos/${cam}/file_index`] ?? row["video_file_index"] ?? 0,
1280
+ );
1281
+ const fromTs = Number(
1282
+ row[`videos/${cam}/from_timestamp`] ??
1283
+ row["video_from_timestamp"] ??
1284
+ 0,
1285
+ );
1286
+ const toTs = Number(
1287
+ row[`videos/${cam}/to_timestamp`] ??
1288
+ row["video_to_timestamp"] ??
1289
+ 30,
1290
+ );
1291
  const videoPath = `videos/${cam}/chunk-${cIdx.toString().padStart(3, "0")}/file-${fIdx.toString().padStart(3, "0")}.mp4`;
1292
  framesByCamera[cam].push({
1293
  episodeIndex: epIdx,
 
1327
 
1328
  // ─── Cross-episode action variance ──────────────────────────────
1329
 
1330
+ export type LowMovementEpisode = {
1331
+ episodeIndex: number;
1332
+ totalMovement: number;
1333
+ };
1334
 
1335
  export type AggVelocityStat = {
1336
  name: string;
 
1391
  maxEpisodes = 500,
1392
  numTimeBins = 50,
1393
  ): Promise<CrossEpisodeVarianceData | null> {
1394
+ const actionEntry = Object.entries(info.features).find(
1395
+ ([key, f]) => key === "action" && f.shape.length === 1,
1396
+ );
1397
  if (!actionEntry) {
1398
+ console.warn(
1399
+ "[cross-ep] No action feature found. Available features:",
1400
+ Object.entries(info.features)
1401
+ .map(([k, f]) => `${k}(${f.dtype}, shape=${JSON.stringify(f.shape)})`)
1402
+ .join(", "),
1403
+ );
1404
  return null;
1405
  }
1406
 
 
1412
  names = Object.values(names)[0];
1413
  }
1414
  const actionNames = Array.isArray(names)
1415
+ ? (names as string[]).map((n) => `${actionKey}${SERIES_NAME_DELIMITER}${n}`)
1416
+ : Array.from(
1417
+ { length: actionDim },
1418
+ (_, i) => `${actionKey}${SERIES_NAME_DELIMITER}${i}`,
1419
+ );
1420
 
1421
  // State feature for alignment computation
1422
+ const stateEntry = Object.entries(info.features).find(
1423
+ ([key, f]) => key === "observation.state" && f.shape.length === 1,
1424
+ );
1425
  const stateKey = stateEntry?.[0] ?? null;
1426
  const stateDim = stateEntry?.[1].shape[0] ?? 0;
1427
 
1428
  // Collect episode metadata
1429
+ type EpMeta = {
1430
+ index: number;
1431
+ chunkIdx: number;
1432
+ fileIdx: number;
1433
+ from: number;
1434
+ to: number;
1435
+ };
1436
  const allEps: EpMeta[] = [];
1437
 
1438
  if (version === "v3.0") {
 
1440
  while (true) {
1441
  const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
1442
  try {
1443
+ const buf = await fetchParquetFile(
1444
+ buildVersionedUrl(repoId, version, path),
1445
+ );
1446
  const rows = await readParquetAsObjects(buf, []);
1447
  if (rows.length === 0 && fileIndex > 0) break;
1448
  for (const row of rows) {
 
1456
  });
1457
  }
1458
  fileIndex++;
1459
+ } catch {
1460
+ break;
1461
+ }
1462
  }
1463
  } else {
1464
  for (let i = 0; i < info.total_episodes; i++) {
 
1467
  }
1468
 
1469
  if (allEps.length < 2) {
1470
+ console.warn(
1471
+ `[cross-ep] Only ${allEps.length} episode(s) found in metadata, need ≥2`,
1472
+ );
1473
  return null;
1474
  }
1475
+ console.log(
1476
+ `[cross-ep] Found ${allEps.length} episodes in metadata, sampling up to ${maxEpisodes}`,
1477
+ );
1478
 
1479
  // Sample episodes evenly
1480
+ const sampled =
1481
+ allEps.length <= maxEpisodes
1482
+ ? allEps
1483
+ : Array.from(
1484
+ { length: maxEpisodes },
1485
+ (_, i) =>
1486
+ allEps[Math.round((i * (allEps.length - 1)) / (maxEpisodes - 1))],
1487
+ );
1488
 
1489
  // Load action (and state) data per episode
1490
  const episodeActions: { index: number; actions: number[][] }[] = [];
 
1502
  const ep0 = eps[0];
1503
  const dataPath = `data/chunk-${ep0.chunkIdx.toString().padStart(3, "0")}/file-${ep0.fileIdx.toString().padStart(3, "0")}.parquet`;
1504
  try {
1505
+ const buf = await fetchParquetFile(
1506
+ buildVersionedUrl(repoId, version, dataPath),
1507
+ );
1508
  const rows = await readParquetAsObjects(buf, []);
1509
+ const fileStart =
1510
+ rows.length > 0 && rows[0].index !== undefined
1511
+ ? Number(rows[0].index)
1512
+ : 0;
1513
 
1514
  for (const ep of eps) {
1515
  const localFrom = Math.max(0, ep.from - fileStart);
 
1526
  }
1527
  if (actions.length > 0) {
1528
  episodeActions.push({ index: ep.index, actions });
1529
+ episodeStates.push(
1530
+ stateKey && states.length === actions.length ? states : null,
1531
+ );
1532
  }
1533
  }
1534
+ } catch {
1535
+ /* skip file */
1536
+ }
1537
  }
1538
  } else {
1539
  const chunkSize = info.chunks_size || 1000;
 
1544
  episode_index: ep.index.toString().padStart(6, "0"),
1545
  });
1546
  try {
1547
+ const buf = await fetchParquetFile(
1548
+ buildVersionedUrl(repoId, version, dataPath),
1549
+ );
1550
  const rows = await readParquetAsObjects(buf, []);
1551
  const actions: number[][] = [];
1552
  const states: number[][] = [];
 
1569
  }
1570
  if (actions.length > 0) {
1571
  episodeActions.push({ index: ep.index, actions });
1572
+ episodeStates.push(
1573
+ stateKey && states.length === actions.length ? states : null,
1574
+ );
1575
  }
1576
+ } catch {
1577
+ /* skip */
1578
+ }
1579
  }
1580
  }
1581
 
1582
  if (episodeActions.length < 2) {
1583
+ console.warn(
1584
+ `[cross-ep] Only ${episodeActions.length} episode(s) had loadable action data out of ${sampled.length} sampled`,
1585
+ );
1586
  return null;
1587
  }
1588
+ console.log(
1589
+ `[cross-ep] Loaded action data for ${episodeActions.length}/${sampled.length} episodes`,
1590
+ );
1591
 
1592
  // Resample each episode to numTimeBins and compute variance
1593
+ const timeBins = Array.from(
1594
+ { length: numTimeBins },
1595
+ (_, i) => i / (numTimeBins - 1),
1596
+ );
1597
+ const sums = Array.from(
1598
+ { length: numTimeBins },
1599
+ () => new Float64Array(actionDim),
1600
+ );
1601
+ const sumsSq = Array.from(
1602
+ { length: numTimeBins },
1603
+ () => new Float64Array(actionDim),
1604
+ );
1605
  const counts = new Uint32Array(numTimeBins);
1606
 
1607
  for (const { actions: epActions } of episodeActions) {
 
1623
  const row: number[] = [];
1624
  const n = counts[b];
1625
  for (let d = 0; d < actionDim; d++) {
1626
+ if (n < 2) {
1627
+ row.push(0);
1628
+ continue;
1629
+ }
1630
  const mean = sums[b][d] / n;
1631
  row.push(sumsSq[b][d] / n - mean * mean);
1632
  }
 
1634
  }
1635
 
1636
  // Per-episode average movement per frame: mean L2 norm of frame-to-frame action deltas
1637
+ const movementScores: LowMovementEpisode[] = episodeActions.map(
1638
+ ({ index, actions: ep }) => {
1639
+ if (ep.length < 2) return { episodeIndex: index, totalMovement: 0 };
1640
+ let total = 0;
1641
+ for (let t = 1; t < ep.length; t++) {
1642
+ let sumSq = 0;
1643
+ for (let d = 0; d < actionDim; d++) {
1644
+ const delta = (ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0);
1645
+ sumSq += delta * delta;
1646
+ }
1647
+ total += Math.sqrt(sumSq);
1648
  }
1649
+ const avgPerFrame = total / (ep.length - 1);
1650
+ return {
1651
+ episodeIndex: index,
1652
+ totalMovement: Math.round(avgPerFrame * 10000) / 10000,
1653
+ };
1654
+ },
1655
+ );
1656
 
1657
  movementScores.sort((a, b) => a.totalMovement - b.totalMovement);
1658
  const lowMovementEpisodes = movementScores.slice(0, 10);
1659
 
1660
  // Aggregated velocity stats: pool deltas from all episodes
1661
+ const shortName = (k: string) => {
1662
+ const p = k.split(SERIES_NAME_DELIMITER);
1663
+ return p.length > 1 ? p[p.length - 1] : k;
1664
+ };
1665
 
1666
  const aggVelocity: AggVelocityStat[] = (() => {
1667
  const binCount = 30;
 
1672
  deltas.push((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0));
1673
  }
1674
  }
1675
+ if (deltas.length === 0)
1676
+ return {
1677
+ name: shortName(actionNames[d]),
1678
+ std: 0,
1679
+ maxAbs: 0,
1680
+ bins: [],
1681
+ lo: 0,
1682
+ hi: 0,
1683
+ };
1684
+ let sum = 0,
1685
+ maxAbs = 0,
1686
+ lo = Infinity,
1687
+ hi = -Infinity;
1688
+ for (const v of deltas) {
1689
+ sum += v;
1690
+ const a = Math.abs(v);
1691
+ if (a > maxAbs) maxAbs = a;
1692
+ if (v < lo) lo = v;
1693
+ if (v > hi) hi = v;
1694
+ }
1695
  const mean = sum / deltas.length;
1696
+ let varSum = 0;
1697
+ for (const v of deltas) varSum += (v - mean) ** 2;
1698
  const std = Math.sqrt(varSum / deltas.length);
1699
  const range = hi - lo || 1;
1700
  const binW = range / binCount;
1701
  const bins = new Array(binCount).fill(0);
1702
+ for (const v of deltas) {
1703
+ let b = Math.floor((v - lo) / binW);
1704
+ if (b >= binCount) b = binCount - 1;
1705
+ bins[b]++;
1706
+ }
1707
  return { name: shortName(actionNames[d]), std, maxAbs, bins, lo, hi };
1708
  });
1709
  })();
1710
 
1711
  // Aggregated autocorrelation: average per-episode ACFs
1712
  const aggAutocorrelation: AggAutocorrelation | null = (() => {
1713
+ const maxLag = Math.min(
1714
+ 100,
1715
+ Math.floor(
1716
+ episodeActions.reduce(
1717
+ (min, e) => Math.min(min, e.actions.length),
1718
+ Infinity,
1719
+ ) / 2,
1720
+ ),
1721
+ );
1722
  if (maxLag < 2) return null;
1723
 
1724
+ const avgAcf: number[][] = Array.from({ length: actionDim }, () =>
1725
+ new Array(maxLag).fill(0),
1726
+ );
1727
  let epCount = 0;
1728
 
1729
  for (const { actions: ep } of episodeActions) {
1730
  if (ep.length < maxLag * 2) continue;
1731
  epCount++;
1732
  for (let d = 0; d < actionDim; d++) {
1733
+ const vals = ep.map((row) => row[d] ?? 0);
1734
  const n = vals.length;
1735
  const m = vals.reduce((a, b) => a + b, 0) / n;
1736
+ const centered = vals.map((v) => v - m);
1737
  const vari = centered.reduce((a, v) => a + v * v, 0);
1738
  if (vari === 0) continue;
1739
  for (let lag = 1; lag <= maxLag; lag++) {
1740
  let s = 0;
1741
+ for (let t = 0; t < n - lag; t++)
1742
+ s += centered[t] * centered[t + lag];
1743
  avgAcf[d][lag - 1] += s / vari;
1744
  }
1745
  }
1746
  }
1747
 
1748
  if (epCount === 0) return null;
1749
+ for (let d = 0; d < actionDim; d++)
1750
+ for (let l = 0; l < maxLag; l++) avgAcf[d][l] /= epCount;
1751
 
1752
  const shortKeys = actionNames.map(shortName);
1753
  const chartData = Array.from({ length: maxLag }, (_, lag) => {
1754
+ const row: Record<string, number> = {
1755
+ lag: lag + 1,
1756
+ time: (lag + 1) / fps,
1757
+ };
1758
+ shortKeys.forEach((k, d) => {
1759
+ row[k] = avgAcf[d][lag];
1760
+ });
1761
  return row;
1762
  });
1763
 
1764
  // Suggested chunk: median lag where ACF drops below 0.5
1765
+ const lags = avgAcf
1766
+ .map((acf) => {
1767
+ const i = acf.findIndex((v) => v < 0.5);
1768
+ return i >= 0 ? i + 1 : null;
1769
+ })
1770
+ .filter(Boolean) as number[];
1771
+ const suggestedChunk =
1772
+ lags.length > 0
1773
+ ? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)]
1774
+ : null;
1775
 
1776
  return { chartData, suggestedChunk, shortKeys };
1777
  })();
1778
 
1779
  // Per-episode jerkiness: mean |Δa| across all dimensions
1780
+ const jerkyEpisodes: JerkyEpisode[] = episodeActions
1781
+ .map(({ index, actions: ep }) => {
1782
+ let sum = 0,
1783
+ count = 0;
1784
+ for (let t = 1; t < ep.length; t++) {
1785
+ for (let d = 0; d < actionDim; d++) {
1786
+ sum += Math.abs((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0));
1787
+ count++;
1788
+ }
1789
  }
1790
+ return { episodeIndex: index, meanAbsDelta: count > 0 ? sum / count : 0 };
1791
+ })
1792
+ .sort((a, b) => b.meanAbsDelta - a.meanAbsDelta);
1793
 
1794
  // Speed distribution: all episode movement scores (not just lowest 10)
1795
+ const speedDistribution: SpeedDistEntry[] = movementScores.map((s) => ({
1796
  episodeIndex: s.episodeIndex,
1797
  speed: s.totalMovement,
1798
  }));
 
1802
  if (!stateKey || stateDim === 0) return null;
1803
 
1804
  let sNms: unknown = stateEntry![1].names;
1805
+ while (typeof sNms === "object" && sNms !== null && !Array.isArray(sNms))
1806
+ sNms = Object.values(sNms)[0];
1807
  const stateNames = Array.isArray(sNms)
1808
  ? (sNms as string[])
1809
  : Array.from({ length: stateDim }, (_, i) => `${i}`);
1810
+ const actionSuffixes = actionNames.map((n) => {
1811
+ const p = n.split(SERIES_NAME_DELIMITER);
1812
+ return p[p.length - 1];
1813
+ });
1814
 
1815
  // Match pairs by suffix, fall back to index
1816
  const pairs: [number, number][] = [];
1817
  for (let ai = 0; ai < actionDim; ai++) {
1818
+ const si = stateNames.findIndex((s) => s === actionSuffixes[ai]);
1819
  if (si >= 0) pairs.push([ai, si]);
1820
  }
1821
  if (pairs.length === 0) {
 
1838
 
1839
  for (let pi = 0; pi < pairs.length; pi++) {
1840
  const [ai, si] = pairs[pi];
1841
+ const aVals = actions.slice(0, n).map((r) => r[ai] ?? 0);
1842
+ const sDeltas = Array.from(
1843
+ { length: n - 1 },
1844
+ (_, t) => (states[t + 1][si] ?? 0) - (states[t][si] ?? 0),
1845
+ );
1846
  const effN = Math.min(aVals.length, sDeltas.length);
1847
  const aM = aVals.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
1848
  const sM = sDeltas.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
1849
 
1850
  for (let li = 0; li < numLags; li++) {
1851
  const lag = -maxLag + li;
1852
+ let sum = 0,
1853
+ aV = 0,
1854
+ sV = 0;
1855
  for (let t = 0; t < effN; t++) {
1856
  const sIdx = t + lag;
1857
  if (sIdx < 0 || sIdx >= sDeltas.length) continue;
1858
+ const a = aVals[t] - aM,
1859
+ s = sDeltas[sIdx] - sM;
1860
+ sum += a * s;
1861
+ aV += a * a;
1862
+ sV += s * s;
1863
  }
1864
  const d = Math.sqrt(aV * sV);
1865
+ if (d > 0) {
1866
+ corrSums[pi][li] += sum / d;
1867
+ corrCounts[pi][li]++;
1868
+ }
1869
  }
1870
  }
1871
  }
1872
 
1873
  const avgCorrs = pairs.map((_, pi) =>
1874
  Array.from({ length: numLags }, (_, li) =>
1875
+ corrCounts[pi][li] > 0 ? corrSums[pi][li] / corrCounts[pi][li] : 0,
1876
+ ),
1877
  );
1878
 
1879
  const ccData = Array.from({ length: numLags }, (_, li) => {
1880
  const lag = -maxLag + li;
1881
+ const vals = avgCorrs.map((pc) => pc[li]);
1882
+ return {
1883
+ lag,
1884
+ max: Math.max(...vals),
1885
+ mean: vals.reduce((a, b) => a + b, 0) / vals.length,
1886
+ min: Math.min(...vals),
1887
+ };
1888
  });
1889
 
1890
+ let meanPeakLag = 0,
1891
+ meanPeakCorr = -Infinity;
1892
+ let maxPeakLag = 0,
1893
+ maxPeakCorr = -Infinity;
1894
+ let minPeakLag = 0,
1895
+ minPeakCorr = -Infinity;
1896
  for (const row of ccData) {
1897
+ if (row.max > maxPeakCorr) {
1898
+ maxPeakCorr = row.max;
1899
+ maxPeakLag = row.lag;
1900
+ }
1901
+ if (row.mean > meanPeakCorr) {
1902
+ meanPeakCorr = row.mean;
1903
+ meanPeakLag = row.lag;
1904
+ }
1905
+ if (row.min > minPeakCorr) {
1906
+ minPeakCorr = row.min;
1907
+ minPeakLag = row.lag;
1908
+ }
1909
  }
1910
 
1911
+ const perPairPeakLags = avgCorrs.map((pc) => {
1912
+ let best = -Infinity,
1913
+ bestLag = 0;
1914
+ for (let li = 0; li < pc.length; li++) {
1915
+ if (pc[li] > best) {
1916
+ best = pc[li];
1917
+ bestLag = -maxLag + li;
1918
+ }
1919
+ }
1920
  return bestLag;
1921
  });
1922
 
1923
  return {
1924
+ ccData,
1925
+ meanPeakLag,
1926
+ meanPeakCorr,
1927
+ maxPeakLag,
1928
+ maxPeakCorr,
1929
+ minPeakLag,
1930
+ minPeakCorr,
1931
+ lagRangeMin: Math.min(...perPairPeakLags),
1932
+ lagRangeMax: Math.max(...perPairPeakLags),
1933
+ numPairs: pairs.length,
1934
  };
1935
  })();
1936
 
1937
  return {
1938
+ actionNames,
1939
+ timeBins,
1940
+ variance,
1941
+ numEpisodes: episodeActions.length,
1942
+ lowMovementEpisodes,
1943
+ aggVelocity,
1944
+ aggAutocorrelation,
1945
+ speedDistribution,
1946
+ jerkyEpisodes,
1947
+ aggAlignment,
1948
  };
1949
  }
1950
 
 
1955
  info: DatasetMetadata,
1956
  episodeId: number,
1957
  ): Promise<Record<string, number>[]> {
1958
+ const episodeMetadata = await loadEpisodeMetadataV3Simple(
1959
+ repoId,
1960
+ version,
1961
+ episodeId,
1962
+ );
1963
+ const { flatChartData } = await loadEpisodeDataV3(
1964
+ repoId,
1965
+ version,
1966
+ info,
1967
+ episodeMetadata,
1968
+ );
1969
  return flatChartData;
1970
  }
1971
 
src/app/page.tsx CHANGED
@@ -6,7 +6,12 @@ import { useSearchParams } from "next/navigation";
6
 
7
  declare global {
8
  interface Window {
9
- YT?: { Player: new (id: string, config: Record<string, unknown>) => { destroy?: () => void } };
 
 
 
 
 
10
  onYouTubeIframeAPIReady?: () => void;
11
  }
12
  }
@@ -87,7 +92,14 @@ function HomeInner() {
87
  start: 0,
88
  },
89
  events: {
90
- onReady: (event: { target: { playVideo: () => void; mute: () => void; seekTo: (t: number) => void; getCurrentTime: () => number } }) => {
 
 
 
 
 
 
 
91
  event.target.playVideo();
92
  event.target.mute();
93
  interval = setInterval(() => {
 
6
 
7
  declare global {
8
  interface Window {
9
+ YT?: {
10
+ Player: new (
11
+ id: string,
12
+ config: Record<string, unknown>,
13
+ ) => { destroy?: () => void };
14
+ };
15
  onYouTubeIframeAPIReady?: () => void;
16
  }
17
  }
 
92
  start: 0,
93
  },
94
  events: {
95
+ onReady: (event: {
96
+ target: {
97
+ playVideo: () => void;
98
+ mute: () => void;
99
+ seekTo: (t: number) => void;
100
+ getCurrentTime: () => number;
101
+ };
102
+ }) => {
103
  event.target.playVideo();
104
  event.target.mute();
105
  interval = setInterval(() => {
src/components/action-insights-panel.tsx CHANGED
@@ -10,7 +10,14 @@ import {
10
  ResponsiveContainer,
11
  Tooltip,
12
  } from "recharts";
13
- import type { CrossEpisodeVarianceData, AggVelocityStat, AggAutocorrelation, SpeedDistEntry, JerkyEpisode, AggAlignment } from "@/app/[org]/[dataset]/[episode]/fetch-data";
 
 
 
 
 
 
 
14
  import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
15
 
16
  const DELIMITER = " | ";
@@ -22,8 +29,26 @@ function InfoToggle({ children }: { children: React.ReactNode }) {
22
  const [open, setOpen] = useState(false);
23
  return (
24
  <>
25
- <button onClick={() => setOpen(v => !v)} className="p-0.5 rounded-full text-slate-500 hover:text-slate-300 transition-colors shrink-0" title="Toggle description">
26
- <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"><circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" /></svg>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  </button>
28
  {open && <div className="mt-1">{children}</div>}
29
  </>
@@ -35,7 +60,9 @@ function FullscreenWrapper({ children }: { children: React.ReactNode }) {
35
 
36
  useEffect(() => {
37
  if (!fs) return;
38
- const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setFs(false); };
 
 
39
  document.addEventListener("keydown", onKey);
40
  return () => document.removeEventListener("keydown", onKey);
41
  }, [fs]);
@@ -43,16 +70,35 @@ function FullscreenWrapper({ children }: { children: React.ReactNode }) {
43
  return (
44
  <div className="relative">
45
  <button
46
- onClick={() => setFs(v => !v)}
47
  className="absolute top-3 right-3 z-10 p-1.5 rounded bg-slate-700/60 hover:bg-slate-600 text-slate-400 hover:text-slate-200 transition-colors backdrop-blur-sm"
48
  title={fs ? "Exit fullscreen" : "Fullscreen"}
49
  >
50
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
51
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
 
 
 
 
 
 
 
 
 
52
  {fs ? (
53
- <><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></>
 
 
 
 
 
54
  ) : (
55
- <><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></>
 
 
 
 
 
56
  )}
57
  </svg>
58
  </button>
@@ -63,14 +109,32 @@ function FullscreenWrapper({ children }: { children: React.ReactNode }) {
63
  className="fixed top-4 right-4 z-50 p-2 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-300 hover:text-white transition-colors"
64
  title="Exit fullscreen (Esc)"
65
  >
66
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
67
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
68
- <polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/>
 
 
 
 
 
 
 
 
 
 
 
 
69
  </svg>
70
  </button>
71
- <div className="max-w-7xl mx-auto"><FullscreenCtx.Provider value={true}>{children}</FullscreenCtx.Provider></div>
 
 
 
 
72
  </div>
73
- ) : children}
 
 
74
  </div>
75
  );
76
  }
@@ -79,11 +143,24 @@ function FlagBtn({ id }: { id: number }) {
79
  const { has, toggle } = useFlaggedEpisodes();
80
  const flagged = has(id);
81
  return (
82
- <button onClick={() => toggle(id)} title={flagged ? "Unflag episode" : "Flag for review"}
83
- className={`p-0.5 rounded transition-colors ${flagged ? "text-orange-400" : "text-slate-600 hover:text-slate-400"}`}>
84
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill={flagged ? "currentColor" : "none"}
85
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
86
- <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  </svg>
88
  </button>
89
  );
@@ -92,20 +169,41 @@ function FlagBtn({ id }: { id: number }) {
92
  function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
93
  const { addMany } = useFlaggedEpisodes();
94
  return (
95
- <button onClick={() => addMany(ids)}
96
- className="text-xs text-slate-500 hover:text-orange-400 transition-colors flex items-center gap-1">
97
- <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none"
98
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
99
- <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
 
 
 
 
 
 
 
 
 
 
 
 
100
  </svg>
101
  {label ?? "Flag all"}
102
  </button>
103
  );
104
  }
105
  const COLORS = [
106
- "#f97316", "#3b82f6", "#22c55e", "#ef4444", "#a855f7",
107
- "#eab308", "#06b6d4", "#ec4899", "#14b8a6", "#f59e0b",
108
- "#6366f1", "#84cc16",
 
 
 
 
 
 
 
 
 
109
  ];
110
 
111
  function shortName(key: string): string {
@@ -115,13 +213,16 @@ function shortName(key: string): string {
115
 
116
  function getActionKeys(row: Record<string, number>): string[] {
117
  return Object.keys(row)
118
- .filter(k => k.startsWith("action") && k !== "timestamp")
119
  .sort();
120
  }
121
 
122
  function getStateKeys(row: Record<string, number>): string[] {
123
  return Object.keys(row)
124
- .filter(k => k.includes("state") && k !== "timestamp" && !k.startsWith("action"))
 
 
 
125
  .sort();
126
  }
127
 
@@ -130,7 +231,7 @@ function getStateKeys(row: Record<string, number>): string[] {
130
  function computeAutocorrelation(values: number[], maxLag: number): number[] {
131
  const n = values.length;
132
  const mean = values.reduce((a, b) => a + b, 0) / n;
133
- const centered = values.map(v => v - mean);
134
  const variance = centered.reduce((a, v) => a + v * v, 0);
135
  if (variance === 0) return Array(maxLag).fill(0);
136
 
@@ -144,98 +245,168 @@ function computeAutocorrelation(values: number[], maxLag: number): number[] {
144
  }
145
 
146
  function findDecorrelationLag(acf: number[], threshold = 0.5): number | null {
147
- const idx = acf.findIndex(v => v < threshold);
148
  return idx >= 0 ? idx + 1 : null;
149
  }
150
 
151
- function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<string, number>[]; fps: number; agg?: AggAutocorrelation | null; numEpisodes?: number }) {
 
 
 
 
 
 
 
 
 
 
152
  const isFs = useIsFullscreen();
153
- const actionKeys = useMemo(() => (data.length > 0 ? getActionKeys(data[0]) : []), [data]);
154
- const maxLag = useMemo(() => Math.min(Math.floor(data.length / 2), 100), [data]);
 
 
 
 
 
 
155
 
156
  const fallback = useMemo(() => {
157
  if (agg) return null;
158
- if (actionKeys.length === 0 || maxLag < 2) return { chartData: [], suggestedChunk: null, shortKeys: [] as string[] };
 
159
 
160
- const acfs = actionKeys.map(key => {
161
- const values = data.map(row => row[key] ?? 0);
162
  return computeAutocorrelation(values, maxLag);
163
  });
164
 
165
  const rows = Array.from({ length: maxLag }, (_, lag) => {
166
- const row: Record<string, number> = { lag: lag + 1, time: (lag + 1) / fps };
167
- actionKeys.forEach((key, ki) => { row[shortName(key)] = acfs[ki][lag]; });
 
 
 
 
 
168
  return row;
169
  });
170
 
171
- const lags = acfs.map(acf => findDecorrelationLag(acf, 0.5)).filter(Boolean) as number[];
172
- const suggested = lags.length > 0 ? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)] : null;
173
-
174
- return { chartData: rows, suggestedChunk: suggested, shortKeys: actionKeys.map(shortName) };
 
 
 
 
 
 
 
 
 
175
  }, [data, actionKeys, maxLag, fps, agg]);
176
 
177
- const { chartData, suggestedChunk, shortKeys } = agg ?? fallback ?? { chartData: [], suggestedChunk: null, shortKeys: [] };
 
178
  const isAgg = !!agg;
179
- const numEpisodesLabel = isAgg ? ` (${numEpisodes} episodes sampled)` : " (current episode)";
 
 
180
 
181
  const yDomain = useMemo(() => {
182
- if (chartData.length === 0 || shortKeys.length === 0) return [-0.2, 1] as [number, number];
 
183
  let min = Infinity;
184
- for (const row of chartData) for (const k of shortKeys) {
185
- const v = row[k];
186
- if (typeof v === "number" && v < min) min = v;
187
- }
 
188
  const lo = Math.floor(Math.min(min, 0) * 10) / 10;
189
  return [lo, 1] as [number, number];
190
  }, [chartData, shortKeys]);
191
 
192
- if (shortKeys.length === 0) return <p className="text-slate-500 italic">No action columns found.</p>;
 
193
 
194
  return (
195
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
196
  <div>
197
  <div className="flex items-center gap-2">
198
- <h3 className="text-sm font-semibold text-slate-200">Action Autocorrelation<span className="text-xs text-slate-500 ml-2 font-normal">{numEpisodesLabel}</span></h3>
 
 
 
 
 
199
  <InfoToggle>
200
  <p className="text-xs text-slate-400">
201
- Shows how correlated each action dimension is with itself over increasing time lags.
202
- Where autocorrelation drops below 0.5 suggests a <span className="text-orange-400 font-medium">natural action chunk boundary</span> — actions
203
- beyond this lag are essentially independent, so executing them open-loop offers diminishing returns.
204
- <br />
205
- <span className="text-slate-500">
206
- Grounded in the theoretical result that chunk length should scale logarithmically with system stability constants
207
- (Zhang et al., 2025 arXiv:2507.09061, Theorem 1).
208
- </span>
209
- </p>
 
 
 
 
 
 
210
  </InfoToggle>
211
  </div>
212
  </div>
213
 
214
  {suggestedChunk && (
215
  <div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
216
- <span className="text-orange-400 font-bold text-lg tabular-nums">{suggestedChunk}</span>
 
 
217
  <div>
218
  <p className="text-sm text-orange-300 font-medium">
219
- Suggested chunk length: {suggestedChunk} steps ({(suggestedChunk / fps).toFixed(2)}s)
 
 
 
 
 
220
  </p>
221
- <p className="text-xs text-slate-400">Median lag where autocorrelation drops below 0.5 across action dimensions</p>
222
  </div>
223
  </div>
224
  )}
225
 
226
  <div className={isFs ? "h-[500px]" : "h-64"}>
227
  <ResponsiveContainer width="100%" height="100%">
228
- <LineChart key={isAgg ? "agg" : "ep"} data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 16 }}>
 
 
 
 
229
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
230
  <XAxis
231
  dataKey="lag"
232
  stroke="#94a3b8"
233
- label={{ value: "Lag (steps)", position: "insideBottom", offset: -8, fill: "#94a3b8", fontSize: 13 }}
 
 
 
 
 
 
234
  />
235
  <YAxis stroke="#94a3b8" domain={yDomain} />
236
  <Tooltip
237
- contentStyle={{ background: "#1e293b", border: "1px solid #475569", borderRadius: 6 }}
238
- labelFormatter={(v) => `Lag ${v} (${(Number(v) / fps).toFixed(2)}s)`}
 
 
 
 
 
 
239
  formatter={(v: number) => v.toFixed(3)}
240
  />
241
  <Line
@@ -266,7 +437,10 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
266
  <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
267
  {shortKeys.map((name, i) => (
268
  <div key={name} className="flex items-center gap-1.5">
269
- <span className="w-3 h-[3px] rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
 
 
 
270
  <span className="text-xs text-slate-400">{name}</span>
271
  </div>
272
  ))}
@@ -277,18 +451,33 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
277
 
278
  // ─── Action Velocity ─────────────────────────────────────────────
279
 
280
- function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data: Record<string, number>[]; agg?: AggVelocityStat[]; numEpisodes?: number; jerkyEpisodes?: JerkyEpisode[] }) {
281
- const actionKeys = useMemo(() => (data.length > 0 ? getActionKeys(data[0]) : []), [data]);
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  const fallbackStats = useMemo(() => {
284
  if (agg && agg.length > 0) return null;
285
  if (actionKeys.length === 0 || data.length < 2) return [];
286
 
287
- return actionKeys.map(key => {
288
- const values = data.map(row => row[key] ?? 0);
289
  const deltas = values.slice(1).map((v, i) => v - values[i]);
290
  const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
291
- const std = Math.sqrt(deltas.reduce((a, d) => a + (d - mean) ** 2, 0) / deltas.length);
 
 
292
  const maxAbs = Math.max(...deltas.map(Math.abs));
293
  const binCount = 30;
294
  const lo = Math.min(...deltas);
@@ -296,25 +485,40 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
296
  const range = hi - lo || 1;
297
  const binW = range / binCount;
298
  const bins: number[] = new Array(binCount).fill(0);
299
- for (const d of deltas) { let b = Math.floor((d - lo) / binW); if (b >= binCount) b = binCount - 1; bins[b]++; }
 
 
 
 
300
  return { name: shortName(key), std, maxAbs, bins, lo, hi };
301
  });
302
  }, [data, actionKeys, agg]);
303
 
304
- const stats = useMemo(() => (agg && agg.length > 0) ? agg : fallbackStats ?? [], [agg, fallbackStats]);
 
 
 
305
  const isAgg = agg && agg.length > 0;
306
 
307
- const maxBinCount = useMemo(() => stats.length > 0 ? Math.max(...stats.flatMap(s => s.bins)) : 0, [stats]);
308
- const maxStd = useMemo(() => stats.length > 0 ? Math.max(...stats.map(s => s.std)) : 1, [stats]);
 
 
 
 
 
 
309
 
310
  const insight = useMemo(() => {
311
  if (stats.length === 0) return null;
312
- const smooth = stats.filter(s => s.std / maxStd < 0.4);
313
- const moderate = stats.filter(s => s.std / maxStd >= 0.4 && s.std / maxStd < 0.7);
314
- const jerky = stats.filter(s => s.std / maxStd >= 0.7);
 
 
315
  const isGripper = (n: string) => /grip/i.test(n);
316
- const jerkyNonGripper = jerky.filter(s => !isGripper(s.name));
317
- const jerkyGripper = jerky.filter(s => isGripper(s.name));
318
  const smoothRatio = smooth.length / stats.length;
319
 
320
  let verdict: { label: string; color: string };
@@ -322,69 +526,125 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
322
  verdict = { label: "Smooth", color: "text-green-400" };
323
  else if (jerkyNonGripper.length <= 2 && smoothRatio >= 0.3)
324
  verdict = { label: "Moderate", color: "text-yellow-400" };
325
- else
326
- verdict = { label: "Jerky", color: "text-red-400" };
327
 
328
  const lines: string[] = [];
329
  if (smooth.length > 0)
330
- lines.push(`${smooth.length} smooth (${smooth.map(s => s.name).join(", ")})`);
 
 
331
  if (moderate.length > 0)
332
- lines.push(`${moderate.length} moderate (${moderate.map(s => s.name).join(", ")})`);
 
 
333
  if (jerkyNonGripper.length > 0)
334
- lines.push(`${jerkyNonGripper.length} jerky (${jerkyNonGripper.map(s => s.name).join(", ")})`);
 
 
335
  if (jerkyGripper.length > 0)
336
- lines.push(`${jerkyGripper.length} gripper${jerkyGripper.length > 1 ? "s" : ""} jerky — expected for binary open/close`);
 
 
337
 
338
  let tip: string;
339
  if (verdict.label === "Smooth")
340
  tip = "Actions are consistent — longer action chunks should work well.";
341
  else if (verdict.label === "Moderate")
342
- tip = "Some dimensions show abrupt changes. Consider moderate chunk sizes.";
 
343
  else
344
- tip = "Many dimensions are jerky. Use shorter action chunks and consider filtering outlier episodes.";
 
345
 
346
  return { verdict, lines, tip };
347
  }, [stats, maxStd]);
348
 
349
- if (stats.length === 0) return <p className="text-slate-500 italic">No action data for velocity analysis.</p>;
 
 
 
 
 
350
 
351
  return (
352
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
353
  <div>
354
  <div className="flex items-center gap-2">
355
- <h3 className="text-sm font-semibold text-slate-200">Action Velocity (Δa) — Smoothness Proxy<span className="text-xs text-slate-500 ml-2 font-normal">{isAgg ? `(${numEpisodes} episodes sampled)` : "(current episode)"}</span></h3>
 
 
 
 
 
 
 
356
  <InfoToggle>
357
  <p className="text-xs text-slate-400">
358
- Shows the distribution of frame-to-frame action changes (Δa = a<sub>t+1</sub> − a<sub>t</sub>) for each dimension.
359
- A <span className="text-green-400">tight distribution around zero</span> means smooth, predictable control — the system
360
- is likely stable and benefits from longer action chunks.
361
- <span className="text-red-400"> Fat tails or high std</span> indicate jerky demonstrations, suggesting shorter chunks
362
- and potentially beneficial noise injection.
363
- <br />
364
- <span className="text-slate-500">
365
- Relates to the Lipschitz constant L<sub>π</sub> and smoothness C<sub>π</sub> in Zhang et al. (2025), which govern
366
- compounding error bounds (Assumptions 3.1, 4.1).
367
- </span>
368
- </p>
 
 
 
 
 
 
369
  </InfoToggle>
370
  </div>
371
  </div>
372
 
373
  {/* Per-dimension mini histograms + stats */}
374
- <div className="grid gap-2" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))" }}>
 
 
 
375
  {stats.map((s, si) => {
376
  const barH = 28;
377
  return (
378
- <div key={s.name} className="bg-slate-900/50 rounded-md px-2.5 py-2 space-y-1">
379
- <p className="text-xs font-medium text-slate-200 truncate" title={s.name}>{s.name}</p>
 
 
 
 
 
 
 
 
380
  <div className="flex gap-2 text-xs text-slate-400 tabular-nums">
381
  <span>σ={s.std.toFixed(4)}</span>
382
- <span>|Δ|<sub>max</sub>={s.maxAbs.toFixed(4)}</span>
 
 
383
  </div>
384
- <svg width="100%" viewBox={`0 0 ${s.bins.length} ${barH}`} preserveAspectRatio="none" className="h-7 rounded" aria-label={`Δa distribution for ${s.name}`}>
 
 
 
 
 
 
385
  {[...s.bins].map((count, bi) => {
386
  const h = maxBinCount > 0 ? (count / maxBinCount) * barH : 0;
387
- return <rect key={bi} x={bi} y={barH - h} width={0.85} height={h} fill={COLORS[si % COLORS.length]} opacity={0.7} />;
 
 
 
 
 
 
 
 
 
 
388
  })}
389
  </svg>
390
  <div className="h-1 w-full bg-slate-700 rounded-full overflow-hidden">
@@ -392,7 +652,12 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
392
  className="h-full rounded-full"
393
  style={{
394
  width: `${Math.min(100, (s.std / maxStd) * 100)}%`,
395
- background: s.std / maxStd < 0.4 ? "#22c55e" : s.std / maxStd < 0.7 ? "#eab308" : "#ef4444",
 
 
 
 
 
396
  }}
397
  />
398
  </div>
@@ -404,16 +669,23 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
404
  {insight && (
405
  <div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-1.5">
406
  <p className="text-sm font-medium text-slate-200">
407
- Overall: <span className={insight.verdict.color}>{insight.verdict.label}</span>
 
 
 
408
  </p>
409
  <ul className="text-xs text-slate-400 space-y-0.5 list-disc list-inside">
410
- {insight.lines.map((l, i) => <li key={i}>{l}</li>)}
 
 
411
  </ul>
412
  <p className="text-xs text-slate-500 pt-1">{insight.tip}</p>
413
  </div>
414
  )}
415
 
416
- {jerkyEpisodes && jerkyEpisodes.length > 0 && <JerkyEpisodesList episodes={jerkyEpisodes} />}
 
 
417
  </div>
418
  );
419
  }
@@ -426,12 +698,18 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
426
  <div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-2">
427
  <div className="flex items-center justify-between">
428
  <p className="text-sm font-medium text-slate-200">
429
- Most Jerky Episodes <span className="text-xs text-slate-500 font-normal">sorted by mean |Δa|</span>
 
 
 
430
  </p>
431
  <div className="flex items-center gap-3">
432
- <FlagAllBtn ids={display.map(e => e.episodeIndex)} />
433
  {episodes.length > 15 && (
434
- <button onClick={() => setShowAll(v => !v)} className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
 
 
 
435
  {showAll ? "Show top 15" : `Show all ${episodes.length}`}
436
  </button>
437
  )}
@@ -447,11 +725,18 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
447
  </tr>
448
  </thead>
449
  <tbody>
450
- {display.map(e => (
451
- <tr key={e.episodeIndex} className="border-b border-slate-800/40 text-slate-300">
452
- <td className="py-1"><FlagBtn id={e.episodeIndex} /></td>
 
 
 
 
 
453
  <td className="py-1 pr-3">ep {e.episodeIndex}</td>
454
- <td className="py-1 text-right tabular-nums">{e.meanAbsDelta.toFixed(4)}</td>
 
 
455
  </tr>
456
  ))}
457
  </tbody>
@@ -463,17 +748,36 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
463
 
464
  // ─── Cross-Episode Variance Heatmap ──────────────────────────────
465
 
466
- function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | null; loading: boolean }) {
 
 
 
 
 
 
467
  const isFs = useIsFullscreen();
468
 
469
  if (loading) {
470
  return (
471
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
472
- <h3 className="text-sm font-semibold text-slate-200 mb-2">Cross-Episode Action Variance</h3>
 
 
473
  <div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
474
  <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
475
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
476
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
 
 
 
 
 
 
 
 
 
 
 
477
  </svg>
478
  Loading cross-episode data (sampled up to 500 episodes)…
479
  </div>
@@ -484,8 +788,12 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
484
  if (!data) {
485
  return (
486
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
487
- <h3 className="text-sm font-semibold text-slate-200 mb-2">Cross-Episode Action Variance</h3>
488
- <p className="text-slate-500 italic text-sm">Not enough episodes or no action data to compute variance.</p>
 
 
 
 
489
  </div>
490
  );
491
  }
@@ -497,8 +805,14 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
497
 
498
  const baseW = isFs ? 1000 : 560;
499
  const baseH = isFs ? 500 : 300;
500
- const cellW = Math.max(6, Math.min(isFs ? 24 : 14, Math.floor(baseW / numBins)));
501
- const cellH = Math.max(20, Math.min(isFs ? 56 : 36, Math.floor(baseH / numDims)));
 
 
 
 
 
 
502
  const labelW = 100;
503
  const svgW = labelW + numBins * cellW + 60;
504
  const svgH = numDims * cellH + 40;
@@ -516,22 +830,32 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
516
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
517
  <div>
518
  <div className="flex items-center gap-2">
519
- <h3 className="text-sm font-semibold text-slate-200">
520
- Cross-Episode Action Variance
521
- <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes sampled)</span>
522
- </h3>
 
 
523
  <InfoToggle>
524
  <p className="text-xs text-slate-400">
525
- Shows how much each action dimension varies across episodes at each point in time (normalized 0–100%).
526
- <span className="text-orange-400"> High-variance regions</span> indicate multi-modal or inconsistent demonstrations —
527
- generative policies (diffusion, flow-matching) and action chunking help here by modeling multiple modes.
528
- <span className="text-blue-400"> Low-variance regions</span> indicate consistent behavior across demonstrations.
529
- <br />
530
- <span className="text-slate-500">
531
- Relates to the &quot;coverage&quot; discussion in Zhang et al. (2025) regions with low variance may lack the
532
- exploratory coverage needed to prevent compounding errors (Section 4).
533
- </span>
534
- </p>
 
 
 
 
 
 
 
 
535
  </InfoToggle>
536
  </div>
537
  </div>
@@ -553,7 +877,7 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
553
  >
554
  <title>{`${shortName(actionNames[di])} @ ${(timeBins[bi] * 100).toFixed(0)}%: var=${v.toFixed(5)}`}</title>
555
  </rect>
556
- ))
557
  )}
558
 
559
  {/* Y-axis: action names */}
@@ -572,7 +896,7 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
572
  ))}
573
 
574
  {/* X-axis labels */}
575
- {[0, 0.25, 0.5, 0.75, 1].map(frac => {
576
  const binIdx = Math.round(frac * (numBins - 1));
577
  return (
578
  <text
@@ -639,28 +963,66 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
639
 
640
  // ─── Demonstrator Speed Variance ────────────────────────────────
641
 
642
- function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: SpeedDistEntry[]; numEpisodes: number }) {
 
 
 
 
 
 
643
  const isFs = useIsFullscreen();
644
- const { speeds, mean, std, cv, median, bins, lo, binW, maxBin, verdict } = useMemo(() => {
645
- const sp = distribution.map(d => d.speed).sort((a, b) => a - b);
646
- const m = sp.reduce((a, b) => a + b, 0) / sp.length;
647
- const s = Math.sqrt(sp.reduce((a, v) => a + (v - m) ** 2, 0) / sp.length);
648
- const c = m > 0 ? s / m : 0;
649
- const med = sp[Math.floor(sp.length / 2)];
650
-
651
- const binCount = Math.min(30, Math.ceil(Math.sqrt(sp.length)));
652
- const lo = sp[0], hi = sp[sp.length - 1];
653
- const bw = (hi - lo || 1) / binCount;
654
- const b = new Array(binCount).fill(0);
655
- for (const v of sp) { let i = Math.floor((v - lo) / bw); if (i >= binCount) i = binCount - 1; b[i]++; }
656
-
657
- let v: { label: string; color: string; tip: string };
658
- if (c < 0.2) v = { label: "Consistent", color: "text-green-400", tip: "Demonstrators execute at similar speeds — no velocity normalization needed." };
659
- else if (c < 0.4) v = { label: "Moderate variance", color: "text-yellow-400", tip: "Some speed variation across demonstrators. Consider velocity normalization for best results." };
660
- else v = { label: "High variance", color: "text-red-400", tip: "Large speed differences between demonstrations. Velocity normalization before training is strongly recommended." };
661
-
662
- return { speeds: sp, mean: m, std: s, cv: c, median: med, bins: b, lo, binW: bw, maxBin: Math.max(...b), verdict: v };
663
- }, [distribution]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
 
665
  if (speeds.length < 3) return null;
666
 
@@ -673,22 +1035,28 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
673
  <div className="flex items-center gap-2">
674
  <h3 className="text-sm font-semibold text-slate-200">
675
  Demonstrator Speed Variance
676
- <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes)</span>
 
 
677
  </h3>
678
  <InfoToggle>
679
  <p className="text-xs text-slate-400">
680
- Distribution of average execution speed (mean ‖Δa<sub>t</sub>‖ per frame) across all episodes.
681
- Different human demonstrators often execute at <span className="text-orange-400">different speeds</span>, creating
682
- artificial multimodality in the action distribution that confuses the policy. A coefficient of variation (CV) above 0.3
 
 
 
683
  strongly suggests normalizing trajectory speed before training.
684
  <br />
685
  <span className="text-slate-500">
686
- Based on &quot;Is Diversity All You Need&quot; (AGI-Bot, 2025) which shows velocity normalization dramatically improves
 
687
  fine-tuning success rate.
688
  </span>
689
  </p>
690
  </InfoToggle>
691
- </div>
692
  </div>
693
 
694
  <div className="flex gap-4">
@@ -699,17 +1067,34 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
699
  const speed = lo + (i + 0.5) * binW;
700
  const ratio = median > 0 ? speed / median : 1;
701
  const dev = Math.abs(ratio - 1);
702
- const color = dev < 0.2 ? "#22c55e" : dev < 0.5 ? "#eab308" : "#ef4444";
 
703
  return (
704
- <rect key={i} x={i * barW} y={barH - h} width={barW - 1} height={Math.max(1, h)} fill={color} opacity={0.7} rx={1}>
 
 
 
 
 
 
 
 
 
705
  <title>{`Speed ${(lo + i * binW).toFixed(3)}–${(lo + (i + 1) * binW).toFixed(3)}: ${count} ep (${ratio.toFixed(2)}× median)`}</title>
706
  </rect>
707
  );
708
  })}
709
- {[0, 0.25, 0.5, 0.75, 1].map(frac => {
710
  const idx = Math.round(frac * (bins.length - 1));
711
  return (
712
- <text key={frac} x={idx * barW + barW / 2} y={barH + 14} textAnchor="middle" className="fill-slate-400" fontSize={9}>
 
 
 
 
 
 
 
713
  {(lo + idx * binW).toFixed(2)}
714
  </text>
715
  );
@@ -717,12 +1102,29 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
717
  </svg>
718
  </div>
719
  <div className="flex flex-col gap-2 text-xs shrink-0 min-w-[120px]">
720
- <div><span className="text-slate-500">Mean</span> <span className="text-slate-200 tabular-nums ml-1">{mean.toFixed(4)}</span></div>
721
- <div><span className="text-slate-500">Median</span> <span className="text-slate-200 tabular-nums ml-1">{median.toFixed(4)}</span></div>
722
- <div><span className="text-slate-500">Std</span> <span className="text-slate-200 tabular-nums ml-1">{std.toFixed(4)}</span></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  <div>
724
  <span className="text-slate-500">CV</span>
725
- <span className={`tabular-nums ml-1 font-bold ${verdict.color}`}>{cv.toFixed(3)}</span>
 
 
726
  </div>
727
  </div>
728
  </div>
@@ -739,7 +1141,17 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
739
 
740
  // ─── State–Action Temporal Alignment ────────────────────────────
741
 
742
- function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Record<string, number>[]; fps: number; agg?: AggAlignment | null; numEpisodes?: number }) {
 
 
 
 
 
 
 
 
 
 
743
  const isFs = useIsFullscreen();
744
  const result = useMemo(() => {
745
  if (agg) return { ...agg, fromAgg: true };
@@ -753,7 +1165,9 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
753
  // Match action↔state by suffix, fall back to index matching
754
  const pairs: [string, string][] = [];
755
  for (const aKey of actionKeys) {
756
- const match = stateKeys.find(sKey => shortName(sKey) === shortName(aKey));
 
 
757
  if (match) pairs.push([aKey, match]);
758
  }
759
  if (pairs.length === 0) {
@@ -765,20 +1179,27 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
765
  // Per-pair cross-correlation
766
  const pairCorrs: number[][] = [];
767
  for (const [aKey, sKey] of pairs) {
768
- const aVals = data.map(row => row[aKey] ?? 0);
769
- const sDeltas = data.slice(1).map((row, i) => (row[sKey] ?? 0) - (data[i][sKey] ?? 0));
 
 
770
  const n = Math.min(aVals.length, sDeltas.length);
771
  const aM = aVals.slice(0, n).reduce((a, b) => a + b, 0) / n;
772
  const sM = sDeltas.slice(0, n).reduce((a, b) => a + b, 0) / n;
773
 
774
  const corrs: number[] = [];
775
  for (let lag = -maxLag; lag <= maxLag; lag++) {
776
- let sum = 0, aV = 0, sV = 0;
 
 
777
  for (let t = 0; t < n; t++) {
778
  const sIdx = t + lag;
779
  if (sIdx < 0 || sIdx >= sDeltas.length) continue;
780
- const a = aVals[t] - aM, s = sDeltas[sIdx] - sM;
781
- sum += a * s; aV += a * a; sV += s * s;
 
 
 
782
  }
783
  const d = Math.sqrt(aV * sV);
784
  corrs.push(d > 0 ? sum / d : 0);
@@ -789,9 +1210,10 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
789
  // Aggregate min/mean/max per lag
790
  const ccData = Array.from({ length: 2 * maxLag + 1 }, (_, li) => {
791
  const lag = -maxLag + li;
792
- const vals = pairCorrs.map(pc => pc[li]);
793
  return {
794
- lag, time: lag / fps,
 
795
  max: Math.max(...vals),
796
  mean: vals.reduce((a, b) => a + b, 0) / vals.length,
797
  min: Math.min(...vals),
@@ -799,32 +1221,74 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
799
  });
800
 
801
  // Peaks of the envelope curves
802
- let meanPeakLag = 0, meanPeakCorr = -Infinity;
803
- let maxPeakLag = 0, maxPeakCorr = -Infinity;
804
- let minPeakLag = 0, minPeakCorr = -Infinity;
 
 
 
805
  for (const row of ccData) {
806
- if (row.max > maxPeakCorr) { maxPeakCorr = row.max; maxPeakLag = row.lag; }
807
- if (row.mean > meanPeakCorr) { meanPeakCorr = row.mean; meanPeakLag = row.lag; }
808
- if (row.min > minPeakCorr) { minPeakCorr = row.min; minPeakLag = row.lag; }
 
 
 
 
 
 
 
 
 
809
  }
810
 
811
  // Per-pair individual peak lags (for showing the true range across dimensions)
812
- const perPairPeakLags = pairCorrs.map(pc => {
813
- let best = -Infinity, bestLag = 0;
 
814
  for (let li = 0; li < pc.length; li++) {
815
- if (pc[li] > best) { best = pc[li]; bestLag = -maxLag + li; }
 
 
 
816
  }
817
  return bestLag;
818
  });
819
  const lagRangeMin = Math.min(...perPairPeakLags);
820
  const lagRangeMax = Math.max(...perPairPeakLags);
821
 
822
- return { ccData, meanPeakLag, meanPeakCorr, maxPeakLag, maxPeakCorr, minPeakLag, minPeakCorr, lagRangeMin, lagRangeMax, numPairs: pairs.length, fromAgg: false };
 
 
 
 
 
 
 
 
 
 
 
 
823
  }, [data, fps, agg]);
824
 
825
  if (!result) return null;
826
- const { ccData, meanPeakLag, meanPeakCorr, maxPeakLag, maxPeakCorr, minPeakLag, minPeakCorr, lagRangeMin, lagRangeMax, numPairs, fromAgg } = result;
827
- const scopeLabel = fromAgg ? `${numEpisodes} episodes sampled` : "current episode";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
 
829
  return (
830
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
@@ -832,20 +1296,27 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
832
  <div className="flex items-center gap-2">
833
  <h3 className="text-sm font-semibold text-slate-200">
834
  State–Action Temporal Alignment
835
- <span className="text-xs text-slate-500 ml-2 font-normal">({scopeLabel}, {numPairs} matched pair{numPairs !== 1 ? "s" : ""})</span>
 
 
836
  </h3>
837
  <InfoToggle>
838
  <p className="text-xs text-slate-400">
839
- Per-dimension cross-correlation between action<sub>d</sub>(t) and Δstate<sub>d</sub>(t+lag), aggregated as
840
- <span className="text-orange-400"> max</span>, <span className="text-slate-200">mean</span>, and
841
- <span className="text-blue-400"> min</span> across all matched action–state pairs.
842
- The <span className="text-orange-400">peak lag</span> reveals the effective control delay — the time between
843
- when an action is commanded and when the corresponding state changes.
 
 
 
 
844
  <br />
845
  <span className="text-slate-500">
846
- Central to ACT (Zhao et al., 2023 — action chunking compensates for delay),
847
- Real-Time Chunking (RTC, 2024), and Training-Time RTC (Biza et al., 2025) — all address
848
- the timing mismatch between commanded actions and observed state changes.
 
849
  </span>
850
  </p>
851
  </InfoToggle>
@@ -854,16 +1325,21 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
854
 
855
  {meanPeakLag !== 0 && (
856
  <div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
857
- <span className="text-orange-400 font-bold text-lg tabular-nums">{meanPeakLag}</span>
 
 
858
  <div>
859
  <p className="text-sm text-orange-300 font-medium">
860
- Mean control delay: {meanPeakLag} step{Math.abs(meanPeakLag) !== 1 ? "s" : ""} ({(meanPeakLag / fps).toFixed(3)}s)
 
 
861
  </p>
862
  <p className="text-xs text-slate-400">
863
  {meanPeakLag > 0
864
  ? `State changes lag behind actions by ~${meanPeakLag} frames on average. Consider aligning action[t] with state[t+${meanPeakLag}].`
865
  : `Actions lag behind state changes by ~${-meanPeakLag} frames on average (predictive actions).`}
866
- {lagRangeMin !== lagRangeMax && ` Individual dimension peaks range from ${lagRangeMin} to ${lagRangeMax} steps.`}
 
867
  </p>
868
  </div>
869
  </div>
@@ -871,49 +1347,102 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
871
 
872
  <div className={isFs ? "h-[500px]" : "h-56"}>
873
  <ResponsiveContainer width="100%" height="100%">
874
- <LineChart data={ccData} margin={{ top: 8, right: 16, left: 0, bottom: 16 }}>
 
 
 
875
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
876
- <XAxis dataKey="lag" stroke="#94a3b8"
877
- label={{ value: "Lag (steps)", position: "insideBottom", offset: -8, fill: "#94a3b8", fontSize: 13 }} />
 
 
 
 
 
 
 
 
 
878
  <YAxis stroke="#94a3b8" domain={[-0.5, 1]} />
879
  <Tooltip
880
- contentStyle={{ background: "#1e293b", border: "1px solid #475569", borderRadius: 6 }}
881
- labelFormatter={(v) => `Lag ${v} (${(Number(v) / fps).toFixed(3)}s)`}
 
 
 
 
 
 
882
  formatter={(v: number) => v.toFixed(3)}
883
  />
884
- <Line dataKey="max" stroke="#f97316" dot={false} strokeWidth={2} isAnimationActive={false} name="max" />
885
- <Line dataKey="mean" stroke="#94a3b8" dot={false} strokeWidth={2} isAnimationActive={false} name="mean" />
886
- <Line dataKey="min" stroke="#3b82f6" dot={false} strokeWidth={2} isAnimationActive={false} name="min" />
887
- <Line dataKey={() => 0} stroke="#64748b" strokeDasharray="6 4" dot={false} name="zero" legendType="none" isAnimationActive={false} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
888
  </LineChart>
889
  </ResponsiveContainer>
890
- </div>
891
 
892
  <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
893
  <div className="flex items-center gap-1.5">
894
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-orange-500" />
895
- <span className="text-xs text-slate-400">max (peak: lag {maxPeakLag}, r={maxPeakCorr.toFixed(3)})</span>
896
- </div>
 
 
897
  <div className="flex items-center gap-1.5">
898
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-slate-400" />
899
- <span className="text-xs text-slate-400">mean (peak: lag {meanPeakLag}, r={meanPeakCorr.toFixed(3)})</span>
900
- </div>
 
 
901
  <div className="flex items-center gap-1.5">
902
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-blue-500" />
903
- <span className="text-xs text-slate-400">min (peak: lag {minPeakLag}, r={minPeakCorr.toFixed(3)})</span>
 
 
904
  </div>
905
  </div>
906
 
907
  {meanPeakLag === 0 && (
908
  <p className="text-xs text-green-400">
909
- Mean peak correlation at lag 0 (r={meanPeakCorr.toFixed(3)}) — actions and state changes are well-aligned in this episode.
 
910
  </p>
911
  )}
912
  </div>
913
  );
914
  }
915
 
916
-
917
  // ─── Main Panel ──────────────────────────────────────────────────
918
 
919
  interface ActionInsightsPanelProps {
@@ -935,38 +1464,74 @@ const ActionInsightsPanel: React.FC<ActionInsightsPanelProps> = ({
935
  return (
936
  <div className="max-w-5xl mx-auto py-6 space-y-8">
937
  <div className="flex items-center justify-between flex-wrap gap-4">
938
- <div>
939
- <h2 className="text-xl font-bold text-slate-100">Action Insights</h2>
940
- <p className="text-sm text-slate-400 mt-1">
941
- Data-driven analysis to guide action chunking, data quality assessment, and training configuration.
942
- </p>
 
943
  </div>
944
  <div className="flex items-center gap-3 shrink-0">
945
- <span className={`text-sm ${mode === "episode" ? "text-slate-100 font-medium" : "text-slate-500"}`}>Current Episode</span>
 
 
 
 
946
  <button
947
- onClick={() => setMode(m => m === "episode" ? "dataset" : "episode")}
 
 
948
  className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${mode === "dataset" ? "bg-orange-500" : "bg-slate-600"}`}
949
  aria-label="Toggle episode/dataset scope"
950
  >
951
- <span className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${mode === "dataset" ? "translate-x-[18px]" : "translate-x-[3px]"}`} />
 
 
952
  </button>
953
- <span className={`text-sm ${mode === "dataset" ? "text-slate-100 font-medium" : "text-slate-500"}`}>
954
- All Episodes{crossEpisodeData ? ` (${crossEpisodeData.numEpisodes})` : ""}
 
 
 
955
  </span>
956
  </div>
957
  </div>
958
 
959
- <FullscreenWrapper><AutocorrelationSection data={flatChartData} fps={fps} agg={showAgg ? crossEpisodeData?.aggAutocorrelation : null} numEpisodes={crossEpisodeData?.numEpisodes} /></FullscreenWrapper>
960
- <FullscreenWrapper><StateActionAlignmentSection data={flatChartData} fps={fps} agg={showAgg ? crossEpisodeData?.aggAlignment : null} numEpisodes={crossEpisodeData?.numEpisodes} /></FullscreenWrapper>
961
-
962
- {crossEpisodeData?.speedDistribution && crossEpisodeData.speedDistribution.length > 2 && (
963
- <FullscreenWrapper><SpeedVarianceSection distribution={crossEpisodeData.speedDistribution} numEpisodes={crossEpisodeData.numEpisodes} /></FullscreenWrapper>
964
- )}
965
- <FullscreenWrapper><VarianceHeatmap data={crossEpisodeData} loading={crossEpisodeLoading} /></FullscreenWrapper>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  </div>
967
  );
968
  };
969
 
970
  export default ActionInsightsPanel;
971
  export { ActionVelocitySection, FullscreenWrapper };
972
-
 
10
  ResponsiveContainer,
11
  Tooltip,
12
  } from "recharts";
13
+ import type {
14
+ CrossEpisodeVarianceData,
15
+ AggVelocityStat,
16
+ AggAutocorrelation,
17
+ SpeedDistEntry,
18
+ JerkyEpisode,
19
+ AggAlignment,
20
+ } from "@/app/[org]/[dataset]/[episode]/fetch-data";
21
  import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
22
 
23
  const DELIMITER = " | ";
 
29
  const [open, setOpen] = useState(false);
30
  return (
31
  <>
32
+ <button
33
+ onClick={() => setOpen((v) => !v)}
34
+ className="p-0.5 rounded-full text-slate-500 hover:text-slate-300 transition-colors shrink-0"
35
+ title="Toggle description"
36
+ >
37
+ <svg
38
+ xmlns="http://www.w3.org/2000/svg"
39
+ width="14"
40
+ height="14"
41
+ viewBox="0 0 24 24"
42
+ fill="none"
43
+ stroke="currentColor"
44
+ strokeWidth="2"
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ >
48
+ <circle cx="12" cy="12" r="10" />
49
+ <line x1="12" y1="16" x2="12" y2="12" />
50
+ <line x1="12" y1="8" x2="12.01" y2="8" />
51
+ </svg>
52
  </button>
53
  {open && <div className="mt-1">{children}</div>}
54
  </>
 
60
 
61
  useEffect(() => {
62
  if (!fs) return;
63
+ const onKey = (e: KeyboardEvent) => {
64
+ if (e.key === "Escape") setFs(false);
65
+ };
66
  document.addEventListener("keydown", onKey);
67
  return () => document.removeEventListener("keydown", onKey);
68
  }, [fs]);
 
70
  return (
71
  <div className="relative">
72
  <button
73
+ onClick={() => setFs((v) => !v)}
74
  className="absolute top-3 right-3 z-10 p-1.5 rounded bg-slate-700/60 hover:bg-slate-600 text-slate-400 hover:text-slate-200 transition-colors backdrop-blur-sm"
75
  title={fs ? "Exit fullscreen" : "Fullscreen"}
76
  >
77
+ <svg
78
+ xmlns="http://www.w3.org/2000/svg"
79
+ width="14"
80
+ height="14"
81
+ viewBox="0 0 24 24"
82
+ fill="none"
83
+ stroke="currentColor"
84
+ strokeWidth="2"
85
+ strokeLinecap="round"
86
+ strokeLinejoin="round"
87
+ >
88
  {fs ? (
89
+ <>
90
+ <polyline points="4 14 10 14 10 20" />
91
+ <polyline points="20 10 14 10 14 4" />
92
+ <line x1="14" y1="10" x2="21" y2="3" />
93
+ <line x1="3" y1="21" x2="10" y2="14" />
94
+ </>
95
  ) : (
96
+ <>
97
+ <polyline points="15 3 21 3 21 9" />
98
+ <polyline points="9 21 3 21 3 15" />
99
+ <line x1="21" y1="3" x2="14" y2="10" />
100
+ <line x1="3" y1="21" x2="10" y2="14" />
101
+ </>
102
  )}
103
  </svg>
104
  </button>
 
109
  className="fixed top-4 right-4 z-50 p-2 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-300 hover:text-white transition-colors"
110
  title="Exit fullscreen (Esc)"
111
  >
112
+ <svg
113
+ xmlns="http://www.w3.org/2000/svg"
114
+ width="16"
115
+ height="16"
116
+ viewBox="0 0 24 24"
117
+ fill="none"
118
+ stroke="currentColor"
119
+ strokeWidth="2"
120
+ strokeLinecap="round"
121
+ strokeLinejoin="round"
122
+ >
123
+ <polyline points="4 14 10 14 10 20" />
124
+ <polyline points="20 10 14 10 14 4" />
125
+ <line x1="14" y1="10" x2="21" y2="3" />
126
+ <line x1="3" y1="21" x2="10" y2="14" />
127
  </svg>
128
  </button>
129
+ <div className="max-w-7xl mx-auto">
130
+ <FullscreenCtx.Provider value={true}>
131
+ {children}
132
+ </FullscreenCtx.Provider>
133
+ </div>
134
  </div>
135
+ ) : (
136
+ children
137
+ )}
138
  </div>
139
  );
140
  }
 
143
  const { has, toggle } = useFlaggedEpisodes();
144
  const flagged = has(id);
145
  return (
146
+ <button
147
+ onClick={() => toggle(id)}
148
+ title={flagged ? "Unflag episode" : "Flag for review"}
149
+ className={`p-0.5 rounded transition-colors ${flagged ? "text-orange-400" : "text-slate-600 hover:text-slate-400"}`}
150
+ >
151
+ <svg
152
+ xmlns="http://www.w3.org/2000/svg"
153
+ width="12"
154
+ height="12"
155
+ viewBox="0 0 24 24"
156
+ fill={flagged ? "currentColor" : "none"}
157
+ stroke="currentColor"
158
+ strokeWidth="2"
159
+ strokeLinecap="round"
160
+ strokeLinejoin="round"
161
+ >
162
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
163
+ <line x1="4" y1="22" x2="4" y2="15" />
164
  </svg>
165
  </button>
166
  );
 
169
  function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
170
  const { addMany } = useFlaggedEpisodes();
171
  return (
172
+ <button
173
+ onClick={() => addMany(ids)}
174
+ className="text-xs text-slate-500 hover:text-orange-400 transition-colors flex items-center gap-1"
175
+ >
176
+ <svg
177
+ xmlns="http://www.w3.org/2000/svg"
178
+ width="10"
179
+ height="10"
180
+ viewBox="0 0 24 24"
181
+ fill="none"
182
+ stroke="currentColor"
183
+ strokeWidth="2"
184
+ strokeLinecap="round"
185
+ strokeLinejoin="round"
186
+ >
187
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
188
+ <line x1="4" y1="22" x2="4" y2="15" />
189
  </svg>
190
  {label ?? "Flag all"}
191
  </button>
192
  );
193
  }
194
  const COLORS = [
195
+ "#f97316",
196
+ "#3b82f6",
197
+ "#22c55e",
198
+ "#ef4444",
199
+ "#a855f7",
200
+ "#eab308",
201
+ "#06b6d4",
202
+ "#ec4899",
203
+ "#14b8a6",
204
+ "#f59e0b",
205
+ "#6366f1",
206
+ "#84cc16",
207
  ];
208
 
209
  function shortName(key: string): string {
 
213
 
214
  function getActionKeys(row: Record<string, number>): string[] {
215
  return Object.keys(row)
216
+ .filter((k) => k.startsWith("action") && k !== "timestamp")
217
  .sort();
218
  }
219
 
220
  function getStateKeys(row: Record<string, number>): string[] {
221
  return Object.keys(row)
222
+ .filter(
223
+ (k) =>
224
+ k.includes("state") && k !== "timestamp" && !k.startsWith("action"),
225
+ )
226
  .sort();
227
  }
228
 
 
231
  function computeAutocorrelation(values: number[], maxLag: number): number[] {
232
  const n = values.length;
233
  const mean = values.reduce((a, b) => a + b, 0) / n;
234
+ const centered = values.map((v) => v - mean);
235
  const variance = centered.reduce((a, v) => a + v * v, 0);
236
  if (variance === 0) return Array(maxLag).fill(0);
237
 
 
245
  }
246
 
247
  function findDecorrelationLag(acf: number[], threshold = 0.5): number | null {
248
+ const idx = acf.findIndex((v) => v < threshold);
249
  return idx >= 0 ? idx + 1 : null;
250
  }
251
 
252
+ function AutocorrelationSection({
253
+ data,
254
+ fps,
255
+ agg,
256
+ numEpisodes,
257
+ }: {
258
+ data: Record<string, number>[];
259
+ fps: number;
260
+ agg?: AggAutocorrelation | null;
261
+ numEpisodes?: number;
262
+ }) {
263
  const isFs = useIsFullscreen();
264
+ const actionKeys = useMemo(
265
+ () => (data.length > 0 ? getActionKeys(data[0]) : []),
266
+ [data],
267
+ );
268
+ const maxLag = useMemo(
269
+ () => Math.min(Math.floor(data.length / 2), 100),
270
+ [data],
271
+ );
272
 
273
  const fallback = useMemo(() => {
274
  if (agg) return null;
275
+ if (actionKeys.length === 0 || maxLag < 2)
276
+ return { chartData: [], suggestedChunk: null, shortKeys: [] as string[] };
277
 
278
+ const acfs = actionKeys.map((key) => {
279
+ const values = data.map((row) => row[key] ?? 0);
280
  return computeAutocorrelation(values, maxLag);
281
  });
282
 
283
  const rows = Array.from({ length: maxLag }, (_, lag) => {
284
+ const row: Record<string, number> = {
285
+ lag: lag + 1,
286
+ time: (lag + 1) / fps,
287
+ };
288
+ actionKeys.forEach((key, ki) => {
289
+ row[shortName(key)] = acfs[ki][lag];
290
+ });
291
  return row;
292
  });
293
 
294
+ const lags = acfs
295
+ .map((acf) => findDecorrelationLag(acf, 0.5))
296
+ .filter(Boolean) as number[];
297
+ const suggested =
298
+ lags.length > 0
299
+ ? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)]
300
+ : null;
301
+
302
+ return {
303
+ chartData: rows,
304
+ suggestedChunk: suggested,
305
+ shortKeys: actionKeys.map(shortName),
306
+ };
307
  }, [data, actionKeys, maxLag, fps, agg]);
308
 
309
+ const { chartData, suggestedChunk, shortKeys } = agg ??
310
+ fallback ?? { chartData: [], suggestedChunk: null, shortKeys: [] };
311
  const isAgg = !!agg;
312
+ const numEpisodesLabel = isAgg
313
+ ? ` (${numEpisodes} episodes sampled)`
314
+ : " (current episode)";
315
 
316
  const yDomain = useMemo(() => {
317
+ if (chartData.length === 0 || shortKeys.length === 0)
318
+ return [-0.2, 1] as [number, number];
319
  let min = Infinity;
320
+ for (const row of chartData)
321
+ for (const k of shortKeys) {
322
+ const v = row[k];
323
+ if (typeof v === "number" && v < min) min = v;
324
+ }
325
  const lo = Math.floor(Math.min(min, 0) * 10) / 10;
326
  return [lo, 1] as [number, number];
327
  }, [chartData, shortKeys]);
328
 
329
+ if (shortKeys.length === 0)
330
+ return <p className="text-slate-500 italic">No action columns found.</p>;
331
 
332
  return (
333
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
334
  <div>
335
  <div className="flex items-center gap-2">
336
+ <h3 className="text-sm font-semibold text-slate-200">
337
+ Action Autocorrelation
338
+ <span className="text-xs text-slate-500 ml-2 font-normal">
339
+ {numEpisodesLabel}
340
+ </span>
341
+ </h3>
342
  <InfoToggle>
343
  <p className="text-xs text-slate-400">
344
+ Shows how correlated each action dimension is with itself over
345
+ increasing time lags. Where autocorrelation drops below 0.5
346
+ suggests a{" "}
347
+ <span className="text-orange-400 font-medium">
348
+ natural action chunk boundary
349
+ </span>{" "}
350
+ actions beyond this lag are essentially independent, so
351
+ executing them open-loop offers diminishing returns.
352
+ <br />
353
+ <span className="text-slate-500">
354
+ Grounded in the theoretical result that chunk length should
355
+ scale logarithmically with system stability constants (Zhang et
356
+ al., 2025 — arXiv:2507.09061, Theorem 1).
357
+ </span>
358
+ </p>
359
  </InfoToggle>
360
  </div>
361
  </div>
362
 
363
  {suggestedChunk && (
364
  <div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
365
+ <span className="text-orange-400 font-bold text-lg tabular-nums">
366
+ {suggestedChunk}
367
+ </span>
368
  <div>
369
  <p className="text-sm text-orange-300 font-medium">
370
+ Suggested chunk length: {suggestedChunk} steps (
371
+ {(suggestedChunk / fps).toFixed(2)}s)
372
+ </p>
373
+ <p className="text-xs text-slate-400">
374
+ Median lag where autocorrelation drops below 0.5 across action
375
+ dimensions
376
  </p>
 
377
  </div>
378
  </div>
379
  )}
380
 
381
  <div className={isFs ? "h-[500px]" : "h-64"}>
382
  <ResponsiveContainer width="100%" height="100%">
383
+ <LineChart
384
+ key={isAgg ? "agg" : "ep"}
385
+ data={chartData}
386
+ margin={{ top: 8, right: 16, left: 0, bottom: 16 }}
387
+ >
388
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
389
  <XAxis
390
  dataKey="lag"
391
  stroke="#94a3b8"
392
+ label={{
393
+ value: "Lag (steps)",
394
+ position: "insideBottom",
395
+ offset: -8,
396
+ fill: "#94a3b8",
397
+ fontSize: 13,
398
+ }}
399
  />
400
  <YAxis stroke="#94a3b8" domain={yDomain} />
401
  <Tooltip
402
+ contentStyle={{
403
+ background: "#1e293b",
404
+ border: "1px solid #475569",
405
+ borderRadius: 6,
406
+ }}
407
+ labelFormatter={(v) =>
408
+ `Lag ${v} (${(Number(v) / fps).toFixed(2)}s)`
409
+ }
410
  formatter={(v: number) => v.toFixed(3)}
411
  />
412
  <Line
 
437
  <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
438
  {shortKeys.map((name, i) => (
439
  <div key={name} className="flex items-center gap-1.5">
440
+ <span
441
+ className="w-3 h-[3px] rounded-full shrink-0"
442
+ style={{ background: COLORS[i % COLORS.length] }}
443
+ />
444
  <span className="text-xs text-slate-400">{name}</span>
445
  </div>
446
  ))}
 
451
 
452
  // ─── Action Velocity ─────────────────────────────────────────────
453
 
454
+ function ActionVelocitySection({
455
+ data,
456
+ agg,
457
+ numEpisodes,
458
+ jerkyEpisodes,
459
+ }: {
460
+ data: Record<string, number>[];
461
+ agg?: AggVelocityStat[];
462
+ numEpisodes?: number;
463
+ jerkyEpisodes?: JerkyEpisode[];
464
+ }) {
465
+ const actionKeys = useMemo(
466
+ () => (data.length > 0 ? getActionKeys(data[0]) : []),
467
+ [data],
468
+ );
469
 
470
  const fallbackStats = useMemo(() => {
471
  if (agg && agg.length > 0) return null;
472
  if (actionKeys.length === 0 || data.length < 2) return [];
473
 
474
+ return actionKeys.map((key) => {
475
+ const values = data.map((row) => row[key] ?? 0);
476
  const deltas = values.slice(1).map((v, i) => v - values[i]);
477
  const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
478
+ const std = Math.sqrt(
479
+ deltas.reduce((a, d) => a + (d - mean) ** 2, 0) / deltas.length,
480
+ );
481
  const maxAbs = Math.max(...deltas.map(Math.abs));
482
  const binCount = 30;
483
  const lo = Math.min(...deltas);
 
485
  const range = hi - lo || 1;
486
  const binW = range / binCount;
487
  const bins: number[] = new Array(binCount).fill(0);
488
+ for (const d of deltas) {
489
+ let b = Math.floor((d - lo) / binW);
490
+ if (b >= binCount) b = binCount - 1;
491
+ bins[b]++;
492
+ }
493
  return { name: shortName(key), std, maxAbs, bins, lo, hi };
494
  });
495
  }, [data, actionKeys, agg]);
496
 
497
+ const stats = useMemo(
498
+ () => (agg && agg.length > 0 ? agg : (fallbackStats ?? [])),
499
+ [agg, fallbackStats],
500
+ );
501
  const isAgg = agg && agg.length > 0;
502
 
503
+ const maxBinCount = useMemo(
504
+ () => (stats.length > 0 ? Math.max(...stats.flatMap((s) => s.bins)) : 0),
505
+ [stats],
506
+ );
507
+ const maxStd = useMemo(
508
+ () => (stats.length > 0 ? Math.max(...stats.map((s) => s.std)) : 1),
509
+ [stats],
510
+ );
511
 
512
  const insight = useMemo(() => {
513
  if (stats.length === 0) return null;
514
+ const smooth = stats.filter((s) => s.std / maxStd < 0.4);
515
+ const moderate = stats.filter(
516
+ (s) => s.std / maxStd >= 0.4 && s.std / maxStd < 0.7,
517
+ );
518
+ const jerky = stats.filter((s) => s.std / maxStd >= 0.7);
519
  const isGripper = (n: string) => /grip/i.test(n);
520
+ const jerkyNonGripper = jerky.filter((s) => !isGripper(s.name));
521
+ const jerkyGripper = jerky.filter((s) => isGripper(s.name));
522
  const smoothRatio = smooth.length / stats.length;
523
 
524
  let verdict: { label: string; color: string };
 
526
  verdict = { label: "Smooth", color: "text-green-400" };
527
  else if (jerkyNonGripper.length <= 2 && smoothRatio >= 0.3)
528
  verdict = { label: "Moderate", color: "text-yellow-400" };
529
+ else verdict = { label: "Jerky", color: "text-red-400" };
 
530
 
531
  const lines: string[] = [];
532
  if (smooth.length > 0)
533
+ lines.push(
534
+ `${smooth.length} smooth (${smooth.map((s) => s.name).join(", ")})`,
535
+ );
536
  if (moderate.length > 0)
537
+ lines.push(
538
+ `${moderate.length} moderate (${moderate.map((s) => s.name).join(", ")})`,
539
+ );
540
  if (jerkyNonGripper.length > 0)
541
+ lines.push(
542
+ `${jerkyNonGripper.length} jerky (${jerkyNonGripper.map((s) => s.name).join(", ")})`,
543
+ );
544
  if (jerkyGripper.length > 0)
545
+ lines.push(
546
+ `${jerkyGripper.length} gripper${jerkyGripper.length > 1 ? "s" : ""} jerky — expected for binary open/close`,
547
+ );
548
 
549
  let tip: string;
550
  if (verdict.label === "Smooth")
551
  tip = "Actions are consistent — longer action chunks should work well.";
552
  else if (verdict.label === "Moderate")
553
+ tip =
554
+ "Some dimensions show abrupt changes. Consider moderate chunk sizes.";
555
  else
556
+ tip =
557
+ "Many dimensions are jerky. Use shorter action chunks and consider filtering outlier episodes.";
558
 
559
  return { verdict, lines, tip };
560
  }, [stats, maxStd]);
561
 
562
+ if (stats.length === 0)
563
+ return (
564
+ <p className="text-slate-500 italic">
565
+ No action data for velocity analysis.
566
+ </p>
567
+ );
568
 
569
  return (
570
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
571
  <div>
572
  <div className="flex items-center gap-2">
573
+ <h3 className="text-sm font-semibold text-slate-200">
574
+ Action Velocity (Δa) — Smoothness Proxy
575
+ <span className="text-xs text-slate-500 ml-2 font-normal">
576
+ {isAgg
577
+ ? `(${numEpisodes} episodes sampled)`
578
+ : "(current episode)"}
579
+ </span>
580
+ </h3>
581
  <InfoToggle>
582
  <p className="text-xs text-slate-400">
583
+ Shows the distribution of frame-to-frame action changes (Δa = a
584
+ <sub>t+1</sub> a<sub>t</sub>) for each dimension. A{" "}
585
+ <span className="text-green-400">
586
+ tight distribution around zero
587
+ </span>{" "}
588
+ means smooth, predictable control — the system is likely stable
589
+ and benefits from longer action chunks.
590
+ <span className="text-red-400"> Fat tails or high std</span>{" "}
591
+ indicate jerky demonstrations, suggesting shorter chunks and
592
+ potentially beneficial noise injection.
593
+ <br />
594
+ <span className="text-slate-500">
595
+ Relates to the Lipschitz constant L<sub>π</sub> and smoothness C
596
+ <sub>π</sub> in Zhang et al. (2025), which govern compounding
597
+ error bounds (Assumptions 3.1, 4.1).
598
+ </span>
599
+ </p>
600
  </InfoToggle>
601
  </div>
602
  </div>
603
 
604
  {/* Per-dimension mini histograms + stats */}
605
+ <div
606
+ className="grid gap-2"
607
+ style={{ gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))" }}
608
+ >
609
  {stats.map((s, si) => {
610
  const barH = 28;
611
  return (
612
+ <div
613
+ key={s.name}
614
+ className="bg-slate-900/50 rounded-md px-2.5 py-2 space-y-1"
615
+ >
616
+ <p
617
+ className="text-xs font-medium text-slate-200 truncate"
618
+ title={s.name}
619
+ >
620
+ {s.name}
621
+ </p>
622
  <div className="flex gap-2 text-xs text-slate-400 tabular-nums">
623
  <span>σ={s.std.toFixed(4)}</span>
624
+ <span>
625
+ |Δ|<sub>max</sub>={s.maxAbs.toFixed(4)}
626
+ </span>
627
  </div>
628
+ <svg
629
+ width="100%"
630
+ viewBox={`0 0 ${s.bins.length} ${barH}`}
631
+ preserveAspectRatio="none"
632
+ className="h-7 rounded"
633
+ aria-label={`Δa distribution for ${s.name}`}
634
+ >
635
  {[...s.bins].map((count, bi) => {
636
  const h = maxBinCount > 0 ? (count / maxBinCount) * barH : 0;
637
+ return (
638
+ <rect
639
+ key={bi}
640
+ x={bi}
641
+ y={barH - h}
642
+ width={0.85}
643
+ height={h}
644
+ fill={COLORS[si % COLORS.length]}
645
+ opacity={0.7}
646
+ />
647
+ );
648
  })}
649
  </svg>
650
  <div className="h-1 w-full bg-slate-700 rounded-full overflow-hidden">
 
652
  className="h-full rounded-full"
653
  style={{
654
  width: `${Math.min(100, (s.std / maxStd) * 100)}%`,
655
+ background:
656
+ s.std / maxStd < 0.4
657
+ ? "#22c55e"
658
+ : s.std / maxStd < 0.7
659
+ ? "#eab308"
660
+ : "#ef4444",
661
  }}
662
  />
663
  </div>
 
669
  {insight && (
670
  <div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-1.5">
671
  <p className="text-sm font-medium text-slate-200">
672
+ Overall:{" "}
673
+ <span className={insight.verdict.color}>
674
+ {insight.verdict.label}
675
+ </span>
676
  </p>
677
  <ul className="text-xs text-slate-400 space-y-0.5 list-disc list-inside">
678
+ {insight.lines.map((l, i) => (
679
+ <li key={i}>{l}</li>
680
+ ))}
681
  </ul>
682
  <p className="text-xs text-slate-500 pt-1">{insight.tip}</p>
683
  </div>
684
  )}
685
 
686
+ {jerkyEpisodes && jerkyEpisodes.length > 0 && (
687
+ <JerkyEpisodesList episodes={jerkyEpisodes} />
688
+ )}
689
  </div>
690
  );
691
  }
 
698
  <div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-2">
699
  <div className="flex items-center justify-between">
700
  <p className="text-sm font-medium text-slate-200">
701
+ Most Jerky Episodes{" "}
702
+ <span className="text-xs text-slate-500 font-normal">
703
+ sorted by mean |Δa|
704
+ </span>
705
  </p>
706
  <div className="flex items-center gap-3">
707
+ <FlagAllBtn ids={display.map((e) => e.episodeIndex)} />
708
  {episodes.length > 15 && (
709
+ <button
710
+ onClick={() => setShowAll((v) => !v)}
711
+ className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
712
+ >
713
  {showAll ? "Show top 15" : `Show all ${episodes.length}`}
714
  </button>
715
  )}
 
725
  </tr>
726
  </thead>
727
  <tbody>
728
+ {display.map((e) => (
729
+ <tr
730
+ key={e.episodeIndex}
731
+ className="border-b border-slate-800/40 text-slate-300"
732
+ >
733
+ <td className="py-1">
734
+ <FlagBtn id={e.episodeIndex} />
735
+ </td>
736
  <td className="py-1 pr-3">ep {e.episodeIndex}</td>
737
+ <td className="py-1 text-right tabular-nums">
738
+ {e.meanAbsDelta.toFixed(4)}
739
+ </td>
740
  </tr>
741
  ))}
742
  </tbody>
 
748
 
749
  // ─── Cross-Episode Variance Heatmap ──────────────────────────────
750
 
751
+ function VarianceHeatmap({
752
+ data,
753
+ loading,
754
+ }: {
755
+ data: CrossEpisodeVarianceData | null;
756
+ loading: boolean;
757
+ }) {
758
  const isFs = useIsFullscreen();
759
 
760
  if (loading) {
761
  return (
762
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
763
+ <h3 className="text-sm font-semibold text-slate-200 mb-2">
764
+ Cross-Episode Action Variance
765
+ </h3>
766
  <div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
767
  <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
768
+ <circle
769
+ className="opacity-25"
770
+ cx="12"
771
+ cy="12"
772
+ r="10"
773
+ stroke="currentColor"
774
+ strokeWidth="4"
775
+ />
776
+ <path
777
+ className="opacity-75"
778
+ fill="currentColor"
779
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
780
+ />
781
  </svg>
782
  Loading cross-episode data (sampled up to 500 episodes)…
783
  </div>
 
788
  if (!data) {
789
  return (
790
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
791
+ <h3 className="text-sm font-semibold text-slate-200 mb-2">
792
+ Cross-Episode Action Variance
793
+ </h3>
794
+ <p className="text-slate-500 italic text-sm">
795
+ Not enough episodes or no action data to compute variance.
796
+ </p>
797
  </div>
798
  );
799
  }
 
805
 
806
  const baseW = isFs ? 1000 : 560;
807
  const baseH = isFs ? 500 : 300;
808
+ const cellW = Math.max(
809
+ 6,
810
+ Math.min(isFs ? 24 : 14, Math.floor(baseW / numBins)),
811
+ );
812
+ const cellH = Math.max(
813
+ 20,
814
+ Math.min(isFs ? 56 : 36, Math.floor(baseH / numDims)),
815
+ );
816
  const labelW = 100;
817
  const svgW = labelW + numBins * cellW + 60;
818
  const svgH = numDims * cellH + 40;
 
830
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
831
  <div>
832
  <div className="flex items-center gap-2">
833
+ <h3 className="text-sm font-semibold text-slate-200">
834
+ Cross-Episode Action Variance
835
+ <span className="text-xs text-slate-500 ml-2 font-normal">
836
+ ({numEpisodes} episodes sampled)
837
+ </span>
838
+ </h3>
839
  <InfoToggle>
840
  <p className="text-xs text-slate-400">
841
+ Shows how much each action dimension varies across episodes at
842
+ each point in time (normalized 0–100%).
843
+ <span className="text-orange-400">
844
+ {" "}
845
+ High-variance regions
846
+ </span>{" "}
847
+ indicate multi-modal or inconsistent demonstrationsgenerative
848
+ policies (diffusion, flow-matching) and action chunking help here
849
+ by modeling multiple modes.
850
+ <span className="text-blue-400"> Low-variance regions</span>{" "}
851
+ indicate consistent behavior across demonstrations.
852
+ <br />
853
+ <span className="text-slate-500">
854
+ Relates to the &quot;coverage&quot; discussion in Zhang et al.
855
+ (2025) — regions with low variance may lack the exploratory
856
+ coverage needed to prevent compounding errors (Section 4).
857
+ </span>
858
+ </p>
859
  </InfoToggle>
860
  </div>
861
  </div>
 
877
  >
878
  <title>{`${shortName(actionNames[di])} @ ${(timeBins[bi] * 100).toFixed(0)}%: var=${v.toFixed(5)}`}</title>
879
  </rect>
880
+ )),
881
  )}
882
 
883
  {/* Y-axis: action names */}
 
896
  ))}
897
 
898
  {/* X-axis labels */}
899
+ {[0, 0.25, 0.5, 0.75, 1].map((frac) => {
900
  const binIdx = Math.round(frac * (numBins - 1));
901
  return (
902
  <text
 
963
 
964
  // ─── Demonstrator Speed Variance ────────────────────────────────
965
 
966
+ function SpeedVarianceSection({
967
+ distribution,
968
+ numEpisodes,
969
+ }: {
970
+ distribution: SpeedDistEntry[];
971
+ numEpisodes: number;
972
+ }) {
973
  const isFs = useIsFullscreen();
974
+ const { speeds, mean, std, cv, median, bins, lo, binW, maxBin, verdict } =
975
+ useMemo(() => {
976
+ const sp = distribution.map((d) => d.speed).sort((a, b) => a - b);
977
+ const m = sp.reduce((a, b) => a + b, 0) / sp.length;
978
+ const s = Math.sqrt(sp.reduce((a, v) => a + (v - m) ** 2, 0) / sp.length);
979
+ const c = m > 0 ? s / m : 0;
980
+ const med = sp[Math.floor(sp.length / 2)];
981
+
982
+ const binCount = Math.min(30, Math.ceil(Math.sqrt(sp.length)));
983
+ const lo = sp[0],
984
+ hi = sp[sp.length - 1];
985
+ const bw = (hi - lo || 1) / binCount;
986
+ const b = new Array(binCount).fill(0);
987
+ for (const v of sp) {
988
+ let i = Math.floor((v - lo) / bw);
989
+ if (i >= binCount) i = binCount - 1;
990
+ b[i]++;
991
+ }
992
+
993
+ let v: { label: string; color: string; tip: string };
994
+ if (c < 0.2)
995
+ v = {
996
+ label: "Consistent",
997
+ color: "text-green-400",
998
+ tip: "Demonstrators execute at similar speeds — no velocity normalization needed.",
999
+ };
1000
+ else if (c < 0.4)
1001
+ v = {
1002
+ label: "Moderate variance",
1003
+ color: "text-yellow-400",
1004
+ tip: "Some speed variation across demonstrators. Consider velocity normalization for best results.",
1005
+ };
1006
+ else
1007
+ v = {
1008
+ label: "High variance",
1009
+ color: "text-red-400",
1010
+ tip: "Large speed differences between demonstrations. Velocity normalization before training is strongly recommended.",
1011
+ };
1012
+
1013
+ return {
1014
+ speeds: sp,
1015
+ mean: m,
1016
+ std: s,
1017
+ cv: c,
1018
+ median: med,
1019
+ bins: b,
1020
+ lo,
1021
+ binW: bw,
1022
+ maxBin: Math.max(...b),
1023
+ verdict: v,
1024
+ };
1025
+ }, [distribution]);
1026
 
1027
  if (speeds.length < 3) return null;
1028
 
 
1035
  <div className="flex items-center gap-2">
1036
  <h3 className="text-sm font-semibold text-slate-200">
1037
  Demonstrator Speed Variance
1038
+ <span className="text-xs text-slate-500 ml-2 font-normal">
1039
+ ({numEpisodes} episodes)
1040
+ </span>
1041
  </h3>
1042
  <InfoToggle>
1043
  <p className="text-xs text-slate-400">
1044
+ Distribution of average execution speed (mean ‖Δa<sub>t</sub>‖ per
1045
+ frame) across all episodes. Different human demonstrators often
1046
+ execute at{" "}
1047
+ <span className="text-orange-400">different speeds</span>,
1048
+ creating artificial multimodality in the action distribution that
1049
+ confuses the policy. A coefficient of variation (CV) above 0.3
1050
  strongly suggests normalizing trajectory speed before training.
1051
  <br />
1052
  <span className="text-slate-500">
1053
+ Based on &quot;Is Diversity All You Need&quot; (AGI-Bot, 2025)
1054
+ which shows velocity normalization dramatically improves
1055
  fine-tuning success rate.
1056
  </span>
1057
  </p>
1058
  </InfoToggle>
1059
+ </div>
1060
  </div>
1061
 
1062
  <div className="flex gap-4">
 
1067
  const speed = lo + (i + 0.5) * binW;
1068
  const ratio = median > 0 ? speed / median : 1;
1069
  const dev = Math.abs(ratio - 1);
1070
+ const color =
1071
+ dev < 0.2 ? "#22c55e" : dev < 0.5 ? "#eab308" : "#ef4444";
1072
  return (
1073
+ <rect
1074
+ key={i}
1075
+ x={i * barW}
1076
+ y={barH - h}
1077
+ width={barW - 1}
1078
+ height={Math.max(1, h)}
1079
+ fill={color}
1080
+ opacity={0.7}
1081
+ rx={1}
1082
+ >
1083
  <title>{`Speed ${(lo + i * binW).toFixed(3)}–${(lo + (i + 1) * binW).toFixed(3)}: ${count} ep (${ratio.toFixed(2)}× median)`}</title>
1084
  </rect>
1085
  );
1086
  })}
1087
+ {[0, 0.25, 0.5, 0.75, 1].map((frac) => {
1088
  const idx = Math.round(frac * (bins.length - 1));
1089
  return (
1090
+ <text
1091
+ key={frac}
1092
+ x={idx * barW + barW / 2}
1093
+ y={barH + 14}
1094
+ textAnchor="middle"
1095
+ className="fill-slate-400"
1096
+ fontSize={9}
1097
+ >
1098
  {(lo + idx * binW).toFixed(2)}
1099
  </text>
1100
  );
 
1102
  </svg>
1103
  </div>
1104
  <div className="flex flex-col gap-2 text-xs shrink-0 min-w-[120px]">
1105
+ <div>
1106
+ <span className="text-slate-500">Mean</span>{" "}
1107
+ <span className="text-slate-200 tabular-nums ml-1">
1108
+ {mean.toFixed(4)}
1109
+ </span>
1110
+ </div>
1111
+ <div>
1112
+ <span className="text-slate-500">Median</span>{" "}
1113
+ <span className="text-slate-200 tabular-nums ml-1">
1114
+ {median.toFixed(4)}
1115
+ </span>
1116
+ </div>
1117
+ <div>
1118
+ <span className="text-slate-500">Std</span>{" "}
1119
+ <span className="text-slate-200 tabular-nums ml-1">
1120
+ {std.toFixed(4)}
1121
+ </span>
1122
+ </div>
1123
  <div>
1124
  <span className="text-slate-500">CV</span>
1125
+ <span className={`tabular-nums ml-1 font-bold ${verdict.color}`}>
1126
+ {cv.toFixed(3)}
1127
+ </span>
1128
  </div>
1129
  </div>
1130
  </div>
 
1141
 
1142
  // ─── State–Action Temporal Alignment ────────────────────────────
1143
 
1144
+ function StateActionAlignmentSection({
1145
+ data,
1146
+ fps,
1147
+ agg,
1148
+ numEpisodes,
1149
+ }: {
1150
+ data: Record<string, number>[];
1151
+ fps: number;
1152
+ agg?: AggAlignment | null;
1153
+ numEpisodes?: number;
1154
+ }) {
1155
  const isFs = useIsFullscreen();
1156
  const result = useMemo(() => {
1157
  if (agg) return { ...agg, fromAgg: true };
 
1165
  // Match action↔state by suffix, fall back to index matching
1166
  const pairs: [string, string][] = [];
1167
  for (const aKey of actionKeys) {
1168
+ const match = stateKeys.find(
1169
+ (sKey) => shortName(sKey) === shortName(aKey),
1170
+ );
1171
  if (match) pairs.push([aKey, match]);
1172
  }
1173
  if (pairs.length === 0) {
 
1179
  // Per-pair cross-correlation
1180
  const pairCorrs: number[][] = [];
1181
  for (const [aKey, sKey] of pairs) {
1182
+ const aVals = data.map((row) => row[aKey] ?? 0);
1183
+ const sDeltas = data
1184
+ .slice(1)
1185
+ .map((row, i) => (row[sKey] ?? 0) - (data[i][sKey] ?? 0));
1186
  const n = Math.min(aVals.length, sDeltas.length);
1187
  const aM = aVals.slice(0, n).reduce((a, b) => a + b, 0) / n;
1188
  const sM = sDeltas.slice(0, n).reduce((a, b) => a + b, 0) / n;
1189
 
1190
  const corrs: number[] = [];
1191
  for (let lag = -maxLag; lag <= maxLag; lag++) {
1192
+ let sum = 0,
1193
+ aV = 0,
1194
+ sV = 0;
1195
  for (let t = 0; t < n; t++) {
1196
  const sIdx = t + lag;
1197
  if (sIdx < 0 || sIdx >= sDeltas.length) continue;
1198
+ const a = aVals[t] - aM,
1199
+ s = sDeltas[sIdx] - sM;
1200
+ sum += a * s;
1201
+ aV += a * a;
1202
+ sV += s * s;
1203
  }
1204
  const d = Math.sqrt(aV * sV);
1205
  corrs.push(d > 0 ? sum / d : 0);
 
1210
  // Aggregate min/mean/max per lag
1211
  const ccData = Array.from({ length: 2 * maxLag + 1 }, (_, li) => {
1212
  const lag = -maxLag + li;
1213
+ const vals = pairCorrs.map((pc) => pc[li]);
1214
  return {
1215
+ lag,
1216
+ time: lag / fps,
1217
  max: Math.max(...vals),
1218
  mean: vals.reduce((a, b) => a + b, 0) / vals.length,
1219
  min: Math.min(...vals),
 
1221
  });
1222
 
1223
  // Peaks of the envelope curves
1224
+ let meanPeakLag = 0,
1225
+ meanPeakCorr = -Infinity;
1226
+ let maxPeakLag = 0,
1227
+ maxPeakCorr = -Infinity;
1228
+ let minPeakLag = 0,
1229
+ minPeakCorr = -Infinity;
1230
  for (const row of ccData) {
1231
+ if (row.max > maxPeakCorr) {
1232
+ maxPeakCorr = row.max;
1233
+ maxPeakLag = row.lag;
1234
+ }
1235
+ if (row.mean > meanPeakCorr) {
1236
+ meanPeakCorr = row.mean;
1237
+ meanPeakLag = row.lag;
1238
+ }
1239
+ if (row.min > minPeakCorr) {
1240
+ minPeakCorr = row.min;
1241
+ minPeakLag = row.lag;
1242
+ }
1243
  }
1244
 
1245
  // Per-pair individual peak lags (for showing the true range across dimensions)
1246
+ const perPairPeakLags = pairCorrs.map((pc) => {
1247
+ let best = -Infinity,
1248
+ bestLag = 0;
1249
  for (let li = 0; li < pc.length; li++) {
1250
+ if (pc[li] > best) {
1251
+ best = pc[li];
1252
+ bestLag = -maxLag + li;
1253
+ }
1254
  }
1255
  return bestLag;
1256
  });
1257
  const lagRangeMin = Math.min(...perPairPeakLags);
1258
  const lagRangeMax = Math.max(...perPairPeakLags);
1259
 
1260
+ return {
1261
+ ccData,
1262
+ meanPeakLag,
1263
+ meanPeakCorr,
1264
+ maxPeakLag,
1265
+ maxPeakCorr,
1266
+ minPeakLag,
1267
+ minPeakCorr,
1268
+ lagRangeMin,
1269
+ lagRangeMax,
1270
+ numPairs: pairs.length,
1271
+ fromAgg: false,
1272
+ };
1273
  }, [data, fps, agg]);
1274
 
1275
  if (!result) return null;
1276
+ const {
1277
+ ccData,
1278
+ meanPeakLag,
1279
+ meanPeakCorr,
1280
+ maxPeakLag,
1281
+ maxPeakCorr,
1282
+ minPeakLag,
1283
+ minPeakCorr,
1284
+ lagRangeMin,
1285
+ lagRangeMax,
1286
+ numPairs,
1287
+ fromAgg,
1288
+ } = result;
1289
+ const scopeLabel = fromAgg
1290
+ ? `${numEpisodes} episodes sampled`
1291
+ : "current episode";
1292
 
1293
  return (
1294
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
 
1296
  <div className="flex items-center gap-2">
1297
  <h3 className="text-sm font-semibold text-slate-200">
1298
  State–Action Temporal Alignment
1299
+ <span className="text-xs text-slate-500 ml-2 font-normal">
1300
+ ({scopeLabel}, {numPairs} matched pair{numPairs !== 1 ? "s" : ""})
1301
+ </span>
1302
  </h3>
1303
  <InfoToggle>
1304
  <p className="text-xs text-slate-400">
1305
+ Per-dimension cross-correlation between action<sub>d</sub>(t) and
1306
+ Δstate<sub>d</sub>(t+lag), aggregated as
1307
+ <span className="text-orange-400"> max</span>,{" "}
1308
+ <span className="text-slate-200">mean</span>, and
1309
+ <span className="text-blue-400"> min</span> across all matched
1310
+ action–state pairs. The{" "}
1311
+ <span className="text-orange-400">peak lag</span> reveals the
1312
+ effective control delay — the time between when an action is
1313
+ commanded and when the corresponding state changes.
1314
  <br />
1315
  <span className="text-slate-500">
1316
+ Central to ACT (Zhao et al., 2023 — action chunking compensates
1317
+ for delay), Real-Time Chunking (RTC, 2024), and Training-Time
1318
+ RTC (Biza et al., 2025) all address the timing mismatch
1319
+ between commanded actions and observed state changes.
1320
  </span>
1321
  </p>
1322
  </InfoToggle>
 
1325
 
1326
  {meanPeakLag !== 0 && (
1327
  <div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
1328
+ <span className="text-orange-400 font-bold text-lg tabular-nums">
1329
+ {meanPeakLag}
1330
+ </span>
1331
  <div>
1332
  <p className="text-sm text-orange-300 font-medium">
1333
+ Mean control delay: {meanPeakLag} step
1334
+ {Math.abs(meanPeakLag) !== 1 ? "s" : ""} (
1335
+ {(meanPeakLag / fps).toFixed(3)}s)
1336
  </p>
1337
  <p className="text-xs text-slate-400">
1338
  {meanPeakLag > 0
1339
  ? `State changes lag behind actions by ~${meanPeakLag} frames on average. Consider aligning action[t] with state[t+${meanPeakLag}].`
1340
  : `Actions lag behind state changes by ~${-meanPeakLag} frames on average (predictive actions).`}
1341
+ {lagRangeMin !== lagRangeMax &&
1342
+ ` Individual dimension peaks range from ${lagRangeMin} to ${lagRangeMax} steps.`}
1343
  </p>
1344
  </div>
1345
  </div>
 
1347
 
1348
  <div className={isFs ? "h-[500px]" : "h-56"}>
1349
  <ResponsiveContainer width="100%" height="100%">
1350
+ <LineChart
1351
+ data={ccData}
1352
+ margin={{ top: 8, right: 16, left: 0, bottom: 16 }}
1353
+ >
1354
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
1355
+ <XAxis
1356
+ dataKey="lag"
1357
+ stroke="#94a3b8"
1358
+ label={{
1359
+ value: "Lag (steps)",
1360
+ position: "insideBottom",
1361
+ offset: -8,
1362
+ fill: "#94a3b8",
1363
+ fontSize: 13,
1364
+ }}
1365
+ />
1366
  <YAxis stroke="#94a3b8" domain={[-0.5, 1]} />
1367
  <Tooltip
1368
+ contentStyle={{
1369
+ background: "#1e293b",
1370
+ border: "1px solid #475569",
1371
+ borderRadius: 6,
1372
+ }}
1373
+ labelFormatter={(v) =>
1374
+ `Lag ${v} (${(Number(v) / fps).toFixed(3)}s)`
1375
+ }
1376
  formatter={(v: number) => v.toFixed(3)}
1377
  />
1378
+ <Line
1379
+ dataKey="max"
1380
+ stroke="#f97316"
1381
+ dot={false}
1382
+ strokeWidth={2}
1383
+ isAnimationActive={false}
1384
+ name="max"
1385
+ />
1386
+ <Line
1387
+ dataKey="mean"
1388
+ stroke="#94a3b8"
1389
+ dot={false}
1390
+ strokeWidth={2}
1391
+ isAnimationActive={false}
1392
+ name="mean"
1393
+ />
1394
+ <Line
1395
+ dataKey="min"
1396
+ stroke="#3b82f6"
1397
+ dot={false}
1398
+ strokeWidth={2}
1399
+ isAnimationActive={false}
1400
+ name="min"
1401
+ />
1402
+ <Line
1403
+ dataKey={() => 0}
1404
+ stroke="#64748b"
1405
+ strokeDasharray="6 4"
1406
+ dot={false}
1407
+ name="zero"
1408
+ legendType="none"
1409
+ isAnimationActive={false}
1410
+ />
1411
  </LineChart>
1412
  </ResponsiveContainer>
1413
+ </div>
1414
 
1415
  <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
1416
  <div className="flex items-center gap-1.5">
1417
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-orange-500" />
1418
+ <span className="text-xs text-slate-400">
1419
+ max (peak: lag {maxPeakLag}, r={maxPeakCorr.toFixed(3)})
1420
+ </span>
1421
+ </div>
1422
  <div className="flex items-center gap-1.5">
1423
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-slate-400" />
1424
+ <span className="text-xs text-slate-400">
1425
+ mean (peak: lag {meanPeakLag}, r={meanPeakCorr.toFixed(3)})
1426
+ </span>
1427
+ </div>
1428
  <div className="flex items-center gap-1.5">
1429
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-blue-500" />
1430
+ <span className="text-xs text-slate-400">
1431
+ min (peak: lag {minPeakLag}, r={minPeakCorr.toFixed(3)})
1432
+ </span>
1433
  </div>
1434
  </div>
1435
 
1436
  {meanPeakLag === 0 && (
1437
  <p className="text-xs text-green-400">
1438
+ Mean peak correlation at lag 0 (r={meanPeakCorr.toFixed(3)}) — actions
1439
+ and state changes are well-aligned in this episode.
1440
  </p>
1441
  )}
1442
  </div>
1443
  );
1444
  }
1445
 
 
1446
  // ─── Main Panel ──────────────────────────────────────────────────
1447
 
1448
  interface ActionInsightsPanelProps {
 
1464
  return (
1465
  <div className="max-w-5xl mx-auto py-6 space-y-8">
1466
  <div className="flex items-center justify-between flex-wrap gap-4">
1467
+ <div>
1468
+ <h2 className="text-xl font-bold text-slate-100">Action Insights</h2>
1469
+ <p className="text-sm text-slate-400 mt-1">
1470
+ Data-driven analysis to guide action chunking, data quality
1471
+ assessment, and training configuration.
1472
+ </p>
1473
  </div>
1474
  <div className="flex items-center gap-3 shrink-0">
1475
+ <span
1476
+ className={`text-sm ${mode === "episode" ? "text-slate-100 font-medium" : "text-slate-500"}`}
1477
+ >
1478
+ Current Episode
1479
+ </span>
1480
  <button
1481
+ onClick={() =>
1482
+ setMode((m) => (m === "episode" ? "dataset" : "episode"))
1483
+ }
1484
  className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${mode === "dataset" ? "bg-orange-500" : "bg-slate-600"}`}
1485
  aria-label="Toggle episode/dataset scope"
1486
  >
1487
+ <span
1488
+ className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${mode === "dataset" ? "translate-x-[18px]" : "translate-x-[3px]"}`}
1489
+ />
1490
  </button>
1491
+ <span
1492
+ className={`text-sm ${mode === "dataset" ? "text-slate-100 font-medium" : "text-slate-500"}`}
1493
+ >
1494
+ All Episodes
1495
+ {crossEpisodeData ? ` (${crossEpisodeData.numEpisodes})` : ""}
1496
  </span>
1497
  </div>
1498
  </div>
1499
 
1500
+ <FullscreenWrapper>
1501
+ <AutocorrelationSection
1502
+ data={flatChartData}
1503
+ fps={fps}
1504
+ agg={showAgg ? crossEpisodeData?.aggAutocorrelation : null}
1505
+ numEpisodes={crossEpisodeData?.numEpisodes}
1506
+ />
1507
+ </FullscreenWrapper>
1508
+ <FullscreenWrapper>
1509
+ <StateActionAlignmentSection
1510
+ data={flatChartData}
1511
+ fps={fps}
1512
+ agg={showAgg ? crossEpisodeData?.aggAlignment : null}
1513
+ numEpisodes={crossEpisodeData?.numEpisodes}
1514
+ />
1515
+ </FullscreenWrapper>
1516
+
1517
+ {crossEpisodeData?.speedDistribution &&
1518
+ crossEpisodeData.speedDistribution.length > 2 && (
1519
+ <FullscreenWrapper>
1520
+ <SpeedVarianceSection
1521
+ distribution={crossEpisodeData.speedDistribution}
1522
+ numEpisodes={crossEpisodeData.numEpisodes}
1523
+ />
1524
+ </FullscreenWrapper>
1525
+ )}
1526
+ <FullscreenWrapper>
1527
+ <VarianceHeatmap
1528
+ data={crossEpisodeData}
1529
+ loading={crossEpisodeLoading}
1530
+ />
1531
+ </FullscreenWrapper>
1532
  </div>
1533
  );
1534
  };
1535
 
1536
  export default ActionInsightsPanel;
1537
  export { ActionVelocitySection, FullscreenWrapper };
 
src/components/data-recharts.tsx CHANGED
@@ -25,14 +25,23 @@ import React, { useMemo } from "react";
25
  const SERIES_NAME_DELIMITER = " | ";
26
 
27
  const CHART_COLORS = [
28
- "#f97316", "#3b82f6", "#22c55e", "#ef4444", "#a855f7",
29
- "#eab308", "#06b6d4", "#ec4899", "#14b8a6", "#f59e0b",
30
- "#6366f1", "#84cc16",
 
 
 
 
 
 
 
 
 
31
  ];
32
 
33
  function mergeGroups(data: ChartRow[][]): ChartRow[] {
34
  if (data.length <= 1) return data[0] ?? [];
35
- const maxLen = Math.max(...data.map(g => g.length));
36
  const merged: ChartRow[] = [];
37
  for (let i = 0; i < maxLen; i++) {
38
  const row: ChartRow = {};
@@ -40,7 +49,10 @@ function mergeGroups(data: ChartRow[][]): ChartRow[] {
40
  const src = group[i];
41
  if (!src) continue;
42
  for (const [k, v] of Object.entries(src)) {
43
- if (k === "timestamp") { row[k] = v; continue; }
 
 
 
44
  row[k] = v;
45
  }
46
  }
@@ -58,7 +70,10 @@ export const DataRecharts = React.memo(
58
  if (typeof onChartsReady === "function") onChartsReady();
59
  }, [onChartsReady]);
60
 
61
- const combinedData = useMemo(() => expanded ? mergeGroups(data) : [], [data, expanded]);
 
 
 
62
 
63
  if (!Array.isArray(data) || data.length === 0) return null;
64
 
@@ -67,19 +82,38 @@ export const DataRecharts = React.memo(
67
  {data.length > 1 && (
68
  <div className="flex justify-end mb-2">
69
  <button
70
- onClick={() => setExpanded(v => !v)}
71
  className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
72
  expanded
73
  ? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
74
  : "bg-slate-800/60 text-slate-400 hover:text-slate-200 border border-slate-700/50"
75
  }`}
76
  >
77
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
78
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
 
 
 
 
 
 
 
 
 
79
  {expanded ? (
80
- <><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></>
 
 
 
 
 
81
  ) : (
82
- <><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></>
 
 
 
 
 
83
  )}
84
  </svg>
85
  {expanded ? "Split charts" : "Combine all"}
@@ -88,11 +122,21 @@ export const DataRecharts = React.memo(
88
  )}
89
 
90
  {expanded ? (
91
- <SingleDataGraph data={combinedData} hoveredTime={hoveredTime} setHoveredTime={setHoveredTime} tall />
 
 
 
 
 
92
  ) : (
93
  <div className="grid md:grid-cols-2 grid-cols-1 gap-4">
94
  {data.map((group, idx) => (
95
- <SingleDataGraph key={idx} data={group} hoveredTime={hoveredTime} setHoveredTime={setHoveredTime} />
 
 
 
 
 
96
  ))}
97
  </div>
98
  )}
@@ -114,7 +158,10 @@ const SingleDataGraph = React.memo(
114
  tall?: boolean;
115
  }) => {
116
  const { currentTime, setCurrentTime } = useTime();
117
- function flattenRow(row: Record<string, number | Record<string, number>>, prefix = ""): Record<string, number> {
 
 
 
118
  const result: Record<string, number> = {};
119
  for (const [key, value] of Object.entries(row)) {
120
  // Special case: if this is a group value that is a primitive, assign to prefix.key
@@ -192,7 +239,9 @@ const SingleDataGraph = React.memo(
192
  setHoveredTime(null);
193
  };
194
 
195
- const handleClick = (data: { activePayload?: { payload: { timestamp: number } }[] } | null) => {
 
 
196
  if (data?.activePayload?.length) {
197
  setCurrentTime(data.activePayload[0].payload.timestamp);
198
  }
@@ -268,13 +317,18 @@ const SingleDataGraph = React.memo(
268
  className="size-3"
269
  style={{ accentColor: color }}
270
  />
271
- <span className="text-xs font-semibold text-slate-200">{group}</span>
 
 
272
  </label>
273
  <div className="pl-5 flex flex-col gap-0.5 mt-0.5">
274
  {children.map((key) => {
275
  const label = key.split(SERIES_NAME_DELIMITER).pop() ?? key;
276
  return (
277
- <label key={key} className="flex items-center gap-1.5 cursor-pointer select-none">
 
 
 
278
  <input
279
  type="checkbox"
280
  checked={visibleKeys.includes(key)}
@@ -282,9 +336,17 @@ const SingleDataGraph = React.memo(
282
  className="size-2.5"
283
  style={{ accentColor: color }}
284
  />
285
- <span className={`text-xs ${visibleKeys.includes(key) ? "text-slate-300" : "text-slate-500"}`}>{label}</span>
286
- <span className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}>
287
- {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "–"}
 
 
 
 
 
 
 
 
288
  </span>
289
  </label>
290
  );
@@ -296,7 +358,10 @@ const SingleDataGraph = React.memo(
296
  {singles.map((key) => {
297
  const color = groupColorMap[key];
298
  return (
299
- <label key={key} className="flex items-center gap-1.5 cursor-pointer select-none">
 
 
 
300
  <input
301
  type="checkbox"
302
  checked={visibleKeys.includes(key)}
@@ -304,9 +369,17 @@ const SingleDataGraph = React.memo(
304
  className="size-3"
305
  style={{ accentColor: color }}
306
  />
307
- <span className={`text-xs ${visibleKeys.includes(key) ? "text-slate-200" : "text-slate-500"}`}>{key}</span>
308
- <span className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}>
309
- {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "–"}
 
 
 
 
 
 
 
 
310
  </span>
311
  </label>
312
  );
@@ -319,7 +392,7 @@ const SingleDataGraph = React.memo(
319
  const chartTitle = useMemo(() => {
320
  const featureNames = Object.keys(groups);
321
  if (featureNames.length > 0) {
322
- const suffixes = featureNames.map(g => {
323
  const parts = g.split(SERIES_NAME_DELIMITER);
324
  return parts[parts.length - 1];
325
  });
@@ -331,9 +404,17 @@ const SingleDataGraph = React.memo(
331
  return (
332
  <div className="w-full bg-slate-800/40 rounded-lg border border-slate-700/50 p-3">
333
  {chartTitle && (
334
- <p className="text-xs font-medium text-slate-300 mb-1 px-1 truncate" title={chartTitle}>{chartTitle}</p>
 
 
 
 
 
335
  )}
336
- <div className={`w-full ${tall ? "h-[500px]" : "h-72"}`} onMouseLeave={handleMouseLeave}>
 
 
 
337
  <ResponsiveContainer width="100%" height="100%">
338
  <LineChart
339
  data={chartData}
@@ -341,12 +422,18 @@ const SingleDataGraph = React.memo(
341
  margin={{ top: 12, right: 12, left: -8, bottom: 8 }}
342
  onClick={handleClick}
343
  onMouseMove={(state) => {
344
- const payload = state?.activePayload?.[0]?.payload as { timestamp?: number } | undefined;
 
 
345
  setHoveredTime(payload?.timestamp ?? null);
346
  }}
347
  onMouseLeave={handleMouseLeave}
348
  >
349
- <CartesianGrid strokeDasharray="3 3" stroke="#334155" strokeOpacity={0.6} />
 
 
 
 
350
  <XAxis
351
  dataKey="timestamp"
352
  domain={[
@@ -384,7 +471,9 @@ const SingleDataGraph = React.memo(
384
  />
385
 
386
  {dataKeys.map((key) => {
387
- const group = key.includes(SERIES_NAME_DELIMITER) ? key.split(SERIES_NAME_DELIMITER)[0] : key;
 
 
388
  const color = groupColorMap[group];
389
  let strokeDasharray: string | undefined = undefined;
390
  if (groups[group] && groups[group].length > 1) {
 
25
  const SERIES_NAME_DELIMITER = " | ";
26
 
27
  const CHART_COLORS = [
28
+ "#f97316",
29
+ "#3b82f6",
30
+ "#22c55e",
31
+ "#ef4444",
32
+ "#a855f7",
33
+ "#eab308",
34
+ "#06b6d4",
35
+ "#ec4899",
36
+ "#14b8a6",
37
+ "#f59e0b",
38
+ "#6366f1",
39
+ "#84cc16",
40
  ];
41
 
42
  function mergeGroups(data: ChartRow[][]): ChartRow[] {
43
  if (data.length <= 1) return data[0] ?? [];
44
+ const maxLen = Math.max(...data.map((g) => g.length));
45
  const merged: ChartRow[] = [];
46
  for (let i = 0; i < maxLen; i++) {
47
  const row: ChartRow = {};
 
49
  const src = group[i];
50
  if (!src) continue;
51
  for (const [k, v] of Object.entries(src)) {
52
+ if (k === "timestamp") {
53
+ row[k] = v;
54
+ continue;
55
+ }
56
  row[k] = v;
57
  }
58
  }
 
70
  if (typeof onChartsReady === "function") onChartsReady();
71
  }, [onChartsReady]);
72
 
73
+ const combinedData = useMemo(
74
+ () => (expanded ? mergeGroups(data) : []),
75
+ [data, expanded],
76
+ );
77
 
78
  if (!Array.isArray(data) || data.length === 0) return null;
79
 
 
82
  {data.length > 1 && (
83
  <div className="flex justify-end mb-2">
84
  <button
85
+ onClick={() => setExpanded((v) => !v)}
86
  className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
87
  expanded
88
  ? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
89
  : "bg-slate-800/60 text-slate-400 hover:text-slate-200 border border-slate-700/50"
90
  }`}
91
  >
92
+ <svg
93
+ xmlns="http://www.w3.org/2000/svg"
94
+ width="12"
95
+ height="12"
96
+ viewBox="0 0 24 24"
97
+ fill="none"
98
+ stroke="currentColor"
99
+ strokeWidth="2"
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ >
103
  {expanded ? (
104
+ <>
105
+ <polyline points="4 14 10 14 10 20" />
106
+ <polyline points="20 10 14 10 14 4" />
107
+ <line x1="14" y1="10" x2="21" y2="3" />
108
+ <line x1="3" y1="21" x2="10" y2="14" />
109
+ </>
110
  ) : (
111
+ <>
112
+ <polyline points="15 3 21 3 21 9" />
113
+ <polyline points="9 21 3 21 3 15" />
114
+ <line x1="21" y1="3" x2="14" y2="10" />
115
+ <line x1="3" y1="21" x2="10" y2="14" />
116
+ </>
117
  )}
118
  </svg>
119
  {expanded ? "Split charts" : "Combine all"}
 
122
  )}
123
 
124
  {expanded ? (
125
+ <SingleDataGraph
126
+ data={combinedData}
127
+ hoveredTime={hoveredTime}
128
+ setHoveredTime={setHoveredTime}
129
+ tall
130
+ />
131
  ) : (
132
  <div className="grid md:grid-cols-2 grid-cols-1 gap-4">
133
  {data.map((group, idx) => (
134
+ <SingleDataGraph
135
+ key={idx}
136
+ data={group}
137
+ hoveredTime={hoveredTime}
138
+ setHoveredTime={setHoveredTime}
139
+ />
140
  ))}
141
  </div>
142
  )}
 
158
  tall?: boolean;
159
  }) => {
160
  const { currentTime, setCurrentTime } = useTime();
161
+ function flattenRow(
162
+ row: Record<string, number | Record<string, number>>,
163
+ prefix = "",
164
+ ): Record<string, number> {
165
  const result: Record<string, number> = {};
166
  for (const [key, value] of Object.entries(row)) {
167
  // Special case: if this is a group value that is a primitive, assign to prefix.key
 
239
  setHoveredTime(null);
240
  };
241
 
242
+ const handleClick = (
243
+ data: { activePayload?: { payload: { timestamp: number } }[] } | null,
244
+ ) => {
245
  if (data?.activePayload?.length) {
246
  setCurrentTime(data.activePayload[0].payload.timestamp);
247
  }
 
317
  className="size-3"
318
  style={{ accentColor: color }}
319
  />
320
+ <span className="text-xs font-semibold text-slate-200">
321
+ {group}
322
+ </span>
323
  </label>
324
  <div className="pl-5 flex flex-col gap-0.5 mt-0.5">
325
  {children.map((key) => {
326
  const label = key.split(SERIES_NAME_DELIMITER).pop() ?? key;
327
  return (
328
+ <label
329
+ key={key}
330
+ className="flex items-center gap-1.5 cursor-pointer select-none"
331
+ >
332
  <input
333
  type="checkbox"
334
  checked={visibleKeys.includes(key)}
 
336
  className="size-2.5"
337
  style={{ accentColor: color }}
338
  />
339
+ <span
340
+ className={`text-xs ${visibleKeys.includes(key) ? "text-slate-300" : "text-slate-500"}`}
341
+ >
342
+ {label}
343
+ </span>
344
+ <span
345
+ className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}
346
+ >
347
+ {typeof currentData[key] === "number"
348
+ ? currentData[key].toFixed(2)
349
+ : "–"}
350
  </span>
351
  </label>
352
  );
 
358
  {singles.map((key) => {
359
  const color = groupColorMap[key];
360
  return (
361
+ <label
362
+ key={key}
363
+ className="flex items-center gap-1.5 cursor-pointer select-none"
364
+ >
365
  <input
366
  type="checkbox"
367
  checked={visibleKeys.includes(key)}
 
369
  className="size-3"
370
  style={{ accentColor: color }}
371
  />
372
+ <span
373
+ className={`text-xs ${visibleKeys.includes(key) ? "text-slate-200" : "text-slate-500"}`}
374
+ >
375
+ {key}
376
+ </span>
377
+ <span
378
+ className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}
379
+ >
380
+ {typeof currentData[key] === "number"
381
+ ? currentData[key].toFixed(2)
382
+ : "–"}
383
  </span>
384
  </label>
385
  );
 
392
  const chartTitle = useMemo(() => {
393
  const featureNames = Object.keys(groups);
394
  if (featureNames.length > 0) {
395
+ const suffixes = featureNames.map((g) => {
396
  const parts = g.split(SERIES_NAME_DELIMITER);
397
  return parts[parts.length - 1];
398
  });
 
404
  return (
405
  <div className="w-full bg-slate-800/40 rounded-lg border border-slate-700/50 p-3">
406
  {chartTitle && (
407
+ <p
408
+ className="text-xs font-medium text-slate-300 mb-1 px-1 truncate"
409
+ title={chartTitle}
410
+ >
411
+ {chartTitle}
412
+ </p>
413
  )}
414
+ <div
415
+ className={`w-full ${tall ? "h-[500px]" : "h-72"}`}
416
+ onMouseLeave={handleMouseLeave}
417
+ >
418
  <ResponsiveContainer width="100%" height="100%">
419
  <LineChart
420
  data={chartData}
 
422
  margin={{ top: 12, right: 12, left: -8, bottom: 8 }}
423
  onClick={handleClick}
424
  onMouseMove={(state) => {
425
+ const payload = state?.activePayload?.[0]?.payload as
426
+ | { timestamp?: number }
427
+ | undefined;
428
  setHoveredTime(payload?.timestamp ?? null);
429
  }}
430
  onMouseLeave={handleMouseLeave}
431
  >
432
+ <CartesianGrid
433
+ strokeDasharray="3 3"
434
+ stroke="#334155"
435
+ strokeOpacity={0.6}
436
+ />
437
  <XAxis
438
  dataKey="timestamp"
439
  domain={[
 
471
  />
472
 
473
  {dataKeys.map((key) => {
474
+ const group = key.includes(SERIES_NAME_DELIMITER)
475
+ ? key.split(SERIES_NAME_DELIMITER)[0]
476
+ : key;
477
  const color = groupColorMap[group];
478
  let strokeDasharray: string | undefined = undefined;
479
  if (groups[group] && groups[group].length > 1) {
src/components/filtering-panel.tsx CHANGED
@@ -8,7 +8,10 @@ import type {
8
  EpisodeLengthStats,
9
  EpisodeLengthInfo,
10
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
11
- import { ActionVelocitySection, FullscreenWrapper } from "@/components/action-insights-panel";
 
 
 
12
 
13
  // ─── Shared small components ─────────────────────────────────────
14
 
@@ -16,11 +19,24 @@ function FlagBtn({ id }: { id: number }) {
16
  const { has, toggle } = useFlaggedEpisodes();
17
  const flagged = has(id);
18
  return (
19
- <button onClick={() => toggle(id)} title={flagged ? "Unflag episode" : "Flag for review"}
20
- className={`p-0.5 rounded transition-colors ${flagged ? "text-orange-400" : "text-slate-600 hover:text-slate-400"}`}>
21
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill={flagged ? "currentColor" : "none"}
22
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
23
- <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  </svg>
25
  </button>
26
  );
@@ -29,11 +45,23 @@ function FlagBtn({ id }: { id: number }) {
29
  function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
30
  const { addMany } = useFlaggedEpisodes();
31
  return (
32
- <button onClick={() => addMany(ids)}
33
- className="text-xs text-slate-500 hover:text-orange-400 transition-colors flex items-center gap-1">
34
- <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none"
35
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
36
- <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
 
 
 
 
 
 
 
 
 
 
 
 
37
  </svg>
38
  {label ?? "Flag all"}
39
  </button>
@@ -44,32 +72,53 @@ function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
44
 
45
  function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
46
  if (episodes.length === 0) return null;
47
- const maxMovement = Math.max(...episodes.map(e => e.totalMovement), 1e-10);
48
 
49
  return (
50
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-3">
51
  <div className="flex items-center justify-between">
52
- <h3 className="text-sm font-semibold text-slate-200">Lowest-Movement Episodes</h3>
53
- <FlagAllBtn ids={episodes.map(e => e.episodeIndex)} />
 
 
54
  </div>
55
  <p className="text-xs text-slate-400">
56
- Episodes with the lowest average action change per frame. Very low values may indicate the robot was standing still or the episode was recorded incorrectly.
 
 
57
  </p>
58
- <div className="grid gap-2" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}>
59
- {episodes.map(ep => (
60
- <div key={ep.episodeIndex} className="bg-slate-900/50 rounded-md px-3 py-2 flex items-center gap-3">
 
 
 
 
 
 
61
  <FlagBtn id={ep.episodeIndex} />
62
- <span className="text-xs text-slate-300 font-medium shrink-0">ep {ep.episodeIndex}</span>
 
 
63
  <div className="flex-1 min-w-0">
64
  <div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
65
- <div className="h-full rounded-full"
 
66
  style={{
67
  width: `${Math.max(2, (ep.totalMovement / maxMovement) * 100)}%`,
68
- background: ep.totalMovement / maxMovement < 0.15 ? "#ef4444" : ep.totalMovement / maxMovement < 0.4 ? "#eab308" : "#22c55e",
69
- }} />
 
 
 
 
 
 
70
  </div>
71
  </div>
72
- <span className="text-xs text-slate-500 tabular-nums shrink-0">{ep.totalMovement.toFixed(2)}</span>
 
 
73
  </div>
74
  ))}
75
  </div>
@@ -81,23 +130,37 @@ function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
81
 
82
  function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
83
  const { addMany } = useFlaggedEpisodes();
84
- const globalMin = useMemo(() => Math.min(...episodes.map((e) => e.lengthSeconds)), [episodes]);
85
- const globalMax = useMemo(() => Math.max(...episodes.map((e) => e.lengthSeconds)), [episodes]);
 
 
 
 
 
 
86
 
87
  const [rangeMin, setRangeMin] = useState(globalMin);
88
  const [rangeMax, setRangeMax] = useState(globalMax);
89
 
90
- const outsideIds = useMemo(() =>
91
- episodes.filter((e) => e.lengthSeconds < rangeMin || e.lengthSeconds > rangeMax)
92
- .map((e) => e.episodeIndex).sort((a, b) => a - b),
93
- [episodes, rangeMin, rangeMax]);
 
 
 
 
94
 
95
  const rangeChanged = rangeMin > globalMin || rangeMax < globalMax;
96
- const step = Math.max(0.01, Math.round((globalMax - globalMin) * 0.001 * 100) / 100) || 0.01;
 
 
97
 
98
  return (
99
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
100
- <h3 className="text-sm font-semibold text-slate-200">Episode Length Filter</h3>
 
 
101
 
102
  <div className="space-y-2">
103
  <div className="flex items-center justify-between text-xs text-slate-400">
@@ -106,28 +169,49 @@ function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
106
  </div>
107
  <div className="relative h-5">
108
  <div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
109
- <div className="absolute top-1/2 -translate-y-1/2 h-1 rounded bg-orange-500"
 
110
  style={{
111
  left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
112
  right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
113
- }} />
114
- <input type="range" min={globalMin} max={globalMax} step={step} value={rangeMin}
115
- onChange={(e) => setRangeMin(Math.min(Number(e.target.value), rangeMax))}
116
- 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" />
117
- <input type="range" min={globalMin} max={globalMax} step={step} value={rangeMax}
118
- onChange={(e) => setRangeMax(Math.max(Number(e.target.value), rangeMin))}
119
- 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" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  </div>
121
  </div>
122
 
123
  {rangeChanged && (
124
  <div className="flex items-center justify-between">
125
  <span className="text-xs text-slate-400">
126
- {outsideIds.length} episode{outsideIds.length !== 1 ? "s" : ""} outside range
 
127
  </span>
128
  {outsideIds.length > 0 && (
129
- <button onClick={() => addMany(outsideIds)}
130
- className="text-xs bg-orange-500/20 text-orange-400 border border-orange-500/40 rounded px-2 py-1 hover:bg-orange-500/30 transition-colors">
 
 
131
  Flag {outsideIds.length} outside range
132
  </button>
133
  )}
@@ -148,7 +232,13 @@ interface FilteringPanelProps {
148
  onViewFlaggedEpisodes?: () => void;
149
  }
150
 
151
- function FlaggedIdsCopyBar({ repoId, onViewEpisodes }: { repoId: string; onViewEpisodes?: () => void }) {
 
 
 
 
 
 
152
  const { flagged, count, clear } = useFlaggedEpisodes();
153
  const [copied, setCopied] = useState(false);
154
 
@@ -168,36 +258,89 @@ function FlaggedIdsCopyBar({ repoId, onViewEpisodes }: { repoId: string; onViewE
168
  <div className="flex items-center justify-between">
169
  <h3 className="text-sm font-semibold text-orange-400">
170
  Flagged Episodes
171
- <span className="text-xs text-slate-500 ml-2 font-normal">({count})</span>
 
 
172
  </h3>
173
  <div className="flex items-center gap-2">
174
- <button onClick={handleCopy}
 
175
  className="text-xs text-slate-400 hover:text-slate-200 transition-colors flex items-center gap-1"
176
- title="Copy IDs">
 
177
  {copied ? (
178
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-green-400"><polyline points="20 6 9 17 4 12" /></svg>
 
 
 
 
 
 
 
 
 
 
 
179
  ) : (
180
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>
 
 
 
 
 
 
 
 
 
 
 
181
  )}
182
  Copy
183
  </button>
184
- <button onClick={clear} className="text-xs text-slate-500 hover:text-red-400 transition-colors">Clear</button>
 
 
 
 
 
185
  </div>
186
  </div>
187
- <p className="text-xs text-slate-300 tabular-nums leading-relaxed max-h-20 overflow-y-auto">{idStr}</p>
 
 
188
  {onViewEpisodes && (
189
- <button onClick={onViewEpisodes}
190
- className="w-full text-xs py-1.5 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-300 hover:text-white transition-colors flex items-center justify-center gap-1.5">
191
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
192
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
193
- <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
 
 
 
 
 
 
 
 
 
 
 
 
194
  </svg>
195
  View flagged episodes
196
  </button>
197
  )}
198
  <div className="bg-slate-900/60 rounded-md px-3 py-2 border border-slate-700/60 space-y-2.5">
199
  <p className="text-xs text-slate-400">
200
- <a href="https://github.com/huggingface/lerobot" target="_blank" rel="noopener noreferrer" className="text-orange-400 underline">LeRobot CLI</a> — delete flagged episodes:
 
 
 
 
 
 
 
 
201
  </p>
202
  <pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes (modifies original dataset)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
203
  <pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes and save to a new dataset (preserves original)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --new_repo_id ${repoId}_filtered \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
@@ -219,11 +362,15 @@ const FilteringPanel: React.FC<FilteringPanelProps> = ({
219
  <div>
220
  <h2 className="text-xl font-bold text-slate-100">Filtering</h2>
221
  <p className="text-sm text-slate-400 mt-1">
222
- Identify and flag problematic episodes for removal. Flagged episodes appear in the sidebar and can be exported as a CLI command.
 
223
  </p>
224
  </div>
225
 
226
- <FlaggedIdsCopyBar repoId={repoId} onViewEpisodes={onViewFlaggedEpisodes} />
 
 
 
227
 
228
  {episodeLengthStats?.allEpisodeLengths && (
229
  <EpisodeLengthFilter episodes={episodeLengthStats.allEpisodeLengths} />
@@ -232,9 +379,24 @@ const FilteringPanel: React.FC<FilteringPanelProps> = ({
232
  {crossEpisodeLoading && (
233
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
234
  <div className="flex items-center gap-2 text-slate-400 text-sm py-4 justify-center">
235
- <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
236
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
237
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  </svg>
239
  Loading cross-episode data…
240
  </div>
@@ -258,4 +420,3 @@ const FilteringPanel: React.FC<FilteringPanelProps> = ({
258
  };
259
 
260
  export default FilteringPanel;
261
-
 
8
  EpisodeLengthStats,
9
  EpisodeLengthInfo,
10
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
11
+ import {
12
+ ActionVelocitySection,
13
+ FullscreenWrapper,
14
+ } from "@/components/action-insights-panel";
15
 
16
  // ─── Shared small components ─────────────────────────────────────
17
 
 
19
  const { has, toggle } = useFlaggedEpisodes();
20
  const flagged = has(id);
21
  return (
22
+ <button
23
+ onClick={() => toggle(id)}
24
+ title={flagged ? "Unflag episode" : "Flag for review"}
25
+ className={`p-0.5 rounded transition-colors ${flagged ? "text-orange-400" : "text-slate-600 hover:text-slate-400"}`}
26
+ >
27
+ <svg
28
+ xmlns="http://www.w3.org/2000/svg"
29
+ width="12"
30
+ height="12"
31
+ viewBox="0 0 24 24"
32
+ fill={flagged ? "currentColor" : "none"}
33
+ stroke="currentColor"
34
+ strokeWidth="2"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ >
38
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
39
+ <line x1="4" y1="22" x2="4" y2="15" />
40
  </svg>
41
  </button>
42
  );
 
45
  function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
46
  const { addMany } = useFlaggedEpisodes();
47
  return (
48
+ <button
49
+ onClick={() => addMany(ids)}
50
+ className="text-xs text-slate-500 hover:text-orange-400 transition-colors flex items-center gap-1"
51
+ >
52
+ <svg
53
+ xmlns="http://www.w3.org/2000/svg"
54
+ width="10"
55
+ height="10"
56
+ viewBox="0 0 24 24"
57
+ fill="none"
58
+ stroke="currentColor"
59
+ strokeWidth="2"
60
+ strokeLinecap="round"
61
+ strokeLinejoin="round"
62
+ >
63
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
64
+ <line x1="4" y1="22" x2="4" y2="15" />
65
  </svg>
66
  {label ?? "Flag all"}
67
  </button>
 
72
 
73
  function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
74
  if (episodes.length === 0) return null;
75
+ const maxMovement = Math.max(...episodes.map((e) => e.totalMovement), 1e-10);
76
 
77
  return (
78
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-3">
79
  <div className="flex items-center justify-between">
80
+ <h3 className="text-sm font-semibold text-slate-200">
81
+ Lowest-Movement Episodes
82
+ </h3>
83
+ <FlagAllBtn ids={episodes.map((e) => e.episodeIndex)} />
84
  </div>
85
  <p className="text-xs text-slate-400">
86
+ Episodes with the lowest average action change per frame. Very low
87
+ values may indicate the robot was standing still or the episode was
88
+ recorded incorrectly.
89
  </p>
90
+ <div
91
+ className="grid gap-2"
92
+ style={{ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}
93
+ >
94
+ {episodes.map((ep) => (
95
+ <div
96
+ key={ep.episodeIndex}
97
+ className="bg-slate-900/50 rounded-md px-3 py-2 flex items-center gap-3"
98
+ >
99
  <FlagBtn id={ep.episodeIndex} />
100
+ <span className="text-xs text-slate-300 font-medium shrink-0">
101
+ ep {ep.episodeIndex}
102
+ </span>
103
  <div className="flex-1 min-w-0">
104
  <div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
105
+ <div
106
+ className="h-full rounded-full"
107
  style={{
108
  width: `${Math.max(2, (ep.totalMovement / maxMovement) * 100)}%`,
109
+ background:
110
+ ep.totalMovement / maxMovement < 0.15
111
+ ? "#ef4444"
112
+ : ep.totalMovement / maxMovement < 0.4
113
+ ? "#eab308"
114
+ : "#22c55e",
115
+ }}
116
+ />
117
  </div>
118
  </div>
119
+ <span className="text-xs text-slate-500 tabular-nums shrink-0">
120
+ {ep.totalMovement.toFixed(2)}
121
+ </span>
122
  </div>
123
  ))}
124
  </div>
 
130
 
131
  function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
132
  const { addMany } = useFlaggedEpisodes();
133
+ const globalMin = useMemo(
134
+ () => Math.min(...episodes.map((e) => e.lengthSeconds)),
135
+ [episodes],
136
+ );
137
+ const globalMax = useMemo(
138
+ () => Math.max(...episodes.map((e) => e.lengthSeconds)),
139
+ [episodes],
140
+ );
141
 
142
  const [rangeMin, setRangeMin] = useState(globalMin);
143
  const [rangeMax, setRangeMax] = useState(globalMax);
144
 
145
+ const outsideIds = useMemo(
146
+ () =>
147
+ episodes
148
+ .filter((e) => e.lengthSeconds < rangeMin || e.lengthSeconds > rangeMax)
149
+ .map((e) => e.episodeIndex)
150
+ .sort((a, b) => a - b),
151
+ [episodes, rangeMin, rangeMax],
152
+ );
153
 
154
  const rangeChanged = rangeMin > globalMin || rangeMax < globalMax;
155
+ const step =
156
+ Math.max(0.01, Math.round((globalMax - globalMin) * 0.001 * 100) / 100) ||
157
+ 0.01;
158
 
159
  return (
160
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
161
+ <h3 className="text-sm font-semibold text-slate-200">
162
+ Episode Length Filter
163
+ </h3>
164
 
165
  <div className="space-y-2">
166
  <div className="flex items-center justify-between text-xs text-slate-400">
 
169
  </div>
170
  <div className="relative h-5">
171
  <div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
172
+ <div
173
+ className="absolute top-1/2 -translate-y-1/2 h-1 rounded bg-orange-500"
174
  style={{
175
  left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
176
  right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
177
+ }}
178
+ />
179
+ <input
180
+ type="range"
181
+ min={globalMin}
182
+ max={globalMax}
183
+ step={step}
184
+ value={rangeMin}
185
+ onChange={(e) =>
186
+ setRangeMin(Math.min(Number(e.target.value), rangeMax))
187
+ }
188
+ 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"
189
+ />
190
+ <input
191
+ type="range"
192
+ min={globalMin}
193
+ max={globalMax}
194
+ step={step}
195
+ value={rangeMax}
196
+ onChange={(e) =>
197
+ setRangeMax(Math.max(Number(e.target.value), rangeMin))
198
+ }
199
+ 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"
200
+ />
201
  </div>
202
  </div>
203
 
204
  {rangeChanged && (
205
  <div className="flex items-center justify-between">
206
  <span className="text-xs text-slate-400">
207
+ {outsideIds.length} episode{outsideIds.length !== 1 ? "s" : ""}{" "}
208
+ outside range
209
  </span>
210
  {outsideIds.length > 0 && (
211
+ <button
212
+ onClick={() => addMany(outsideIds)}
213
+ className="text-xs bg-orange-500/20 text-orange-400 border border-orange-500/40 rounded px-2 py-1 hover:bg-orange-500/30 transition-colors"
214
+ >
215
  Flag {outsideIds.length} outside range
216
  </button>
217
  )}
 
232
  onViewFlaggedEpisodes?: () => void;
233
  }
234
 
235
+ function FlaggedIdsCopyBar({
236
+ repoId,
237
+ onViewEpisodes,
238
+ }: {
239
+ repoId: string;
240
+ onViewEpisodes?: () => void;
241
+ }) {
242
  const { flagged, count, clear } = useFlaggedEpisodes();
243
  const [copied, setCopied] = useState(false);
244
 
 
258
  <div className="flex items-center justify-between">
259
  <h3 className="text-sm font-semibold text-orange-400">
260
  Flagged Episodes
261
+ <span className="text-xs text-slate-500 ml-2 font-normal">
262
+ ({count})
263
+ </span>
264
  </h3>
265
  <div className="flex items-center gap-2">
266
+ <button
267
+ onClick={handleCopy}
268
  className="text-xs text-slate-400 hover:text-slate-200 transition-colors flex items-center gap-1"
269
+ title="Copy IDs"
270
+ >
271
  {copied ? (
272
+ <svg
273
+ xmlns="http://www.w3.org/2000/svg"
274
+ width="12"
275
+ height="12"
276
+ viewBox="0 0 24 24"
277
+ fill="none"
278
+ stroke="currentColor"
279
+ strokeWidth="2"
280
+ className="text-green-400"
281
+ >
282
+ <polyline points="20 6 9 17 4 12" />
283
+ </svg>
284
  ) : (
285
+ <svg
286
+ xmlns="http://www.w3.org/2000/svg"
287
+ width="12"
288
+ height="12"
289
+ viewBox="0 0 24 24"
290
+ fill="none"
291
+ stroke="currentColor"
292
+ strokeWidth="2"
293
+ >
294
+ <rect x="9" y="9" width="13" height="13" rx="2" />
295
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
296
+ </svg>
297
  )}
298
  Copy
299
  </button>
300
+ <button
301
+ onClick={clear}
302
+ className="text-xs text-slate-500 hover:text-red-400 transition-colors"
303
+ >
304
+ Clear
305
+ </button>
306
  </div>
307
  </div>
308
+ <p className="text-xs text-slate-300 tabular-nums leading-relaxed max-h-20 overflow-y-auto">
309
+ {idStr}
310
+ </p>
311
  {onViewEpisodes && (
312
+ <button
313
+ onClick={onViewEpisodes}
314
+ className="w-full text-xs py-1.5 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-300 hover:text-white transition-colors flex items-center justify-center gap-1.5"
315
+ >
316
+ <svg
317
+ xmlns="http://www.w3.org/2000/svg"
318
+ width="12"
319
+ height="12"
320
+ viewBox="0 0 24 24"
321
+ fill="none"
322
+ stroke="currentColor"
323
+ strokeWidth="2"
324
+ strokeLinecap="round"
325
+ strokeLinejoin="round"
326
+ >
327
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
328
+ <line x1="4" y1="22" x2="4" y2="15" />
329
  </svg>
330
  View flagged episodes
331
  </button>
332
  )}
333
  <div className="bg-slate-900/60 rounded-md px-3 py-2 border border-slate-700/60 space-y-2.5">
334
  <p className="text-xs text-slate-400">
335
+ <a
336
+ href="https://github.com/huggingface/lerobot"
337
+ target="_blank"
338
+ rel="noopener noreferrer"
339
+ className="text-orange-400 underline"
340
+ >
341
+ LeRobot CLI
342
+ </a>{" "}
343
+ — delete flagged episodes:
344
  </p>
345
  <pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes (modifies original dataset)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
346
  <pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes and save to a new dataset (preserves original)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --new_repo_id ${repoId}_filtered \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
 
362
  <div>
363
  <h2 className="text-xl font-bold text-slate-100">Filtering</h2>
364
  <p className="text-sm text-slate-400 mt-1">
365
+ Identify and flag problematic episodes for removal. Flagged episodes
366
+ appear in the sidebar and can be exported as a CLI command.
367
  </p>
368
  </div>
369
 
370
+ <FlaggedIdsCopyBar
371
+ repoId={repoId}
372
+ onViewEpisodes={onViewFlaggedEpisodes}
373
+ />
374
 
375
  {episodeLengthStats?.allEpisodeLengths && (
376
  <EpisodeLengthFilter episodes={episodeLengthStats.allEpisodeLengths} />
 
379
  {crossEpisodeLoading && (
380
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
381
  <div className="flex items-center gap-2 text-slate-400 text-sm py-4 justify-center">
382
+ <svg
383
+ className="animate-spin h-4 w-4"
384
+ viewBox="0 0 24 24"
385
+ fill="none"
386
+ >
387
+ <circle
388
+ className="opacity-25"
389
+ cx="12"
390
+ cy="12"
391
+ r="10"
392
+ stroke="currentColor"
393
+ strokeWidth="4"
394
+ />
395
+ <path
396
+ className="opacity-75"
397
+ fill="currentColor"
398
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
399
+ />
400
  </svg>
401
  Loading cross-episode data…
402
  </div>
 
420
  };
421
 
422
  export default FilteringPanel;
 
src/components/overview-panel.tsx CHANGED
@@ -1,12 +1,21 @@
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
  import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
6
 
7
  const PAGE_SIZE = 48;
8
 
9
- function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast: boolean }) {
 
 
 
 
 
 
10
  const containerRef = useRef<HTMLDivElement>(null);
11
  const videoRef = useRef<HTMLVideoElement>(null);
12
  const [inView, setInView] = useState(false);
@@ -15,7 +24,12 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
15
  const el = containerRef.current;
16
  if (!el) return;
17
  const obs = new IntersectionObserver(
18
- ([e]) => { if (e.isIntersecting) { setInView(true); obs.disconnect(); } },
 
 
 
 
 
19
  { rootMargin: "200px" },
20
  );
21
  obs.observe(el);
@@ -28,7 +42,8 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
28
 
29
  const seek = () => {
30
  if (showLast) {
31
- video.currentTime = info.lastFrameTime ?? Math.max(0, video.duration - 0.05);
 
32
  } else {
33
  video.currentTime = info.firstFrameTime;
34
  }
@@ -62,18 +77,33 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
62
  <button
63
  onClick={() => toggle(info.episodeIndex)}
64
  className={`absolute top-1 right-1 p-1 rounded transition-opacity ${
65
- isFlagged ? "opacity-100 text-orange-400" : "opacity-0 group-hover:opacity-100 text-slate-400 hover:text-orange-400"
 
 
66
  }`}
67
  title={isFlagged ? "Unflag episode" : "Flag episode"}
68
  >
69
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill={isFlagged ? "currentColor" : "none"}
70
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
71
- <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
 
 
 
 
 
 
 
 
 
 
72
  </svg>
73
  </button>
74
  </div>
75
- <p className={`text-xs mt-1 tabular-nums ${isFlagged ? "text-orange-400" : "text-slate-400"}`}>
76
- ep {info.episodeIndex}{isFlagged ? "" : ""}
 
 
 
77
  </p>
78
  </div>
79
  );
@@ -86,7 +116,12 @@ interface OverviewPanelProps {
86
  onFlaggedOnlyChange?: (v: boolean) => void;
87
  }
88
 
89
- export default function OverviewPanel({ data, loading, flaggedOnly = false, onFlaggedOnlyChange }: OverviewPanelProps) {
 
 
 
 
 
90
  const { flagged, count: flagCount } = useFlaggedEpisodes();
91
  const [selectedCamera, setSelectedCamera] = useState<string>("");
92
  const [showLast, setShowLast] = useState(false);
@@ -99,17 +134,31 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
99
  }
100
  }, [data, selectedCamera]);
101
 
102
- const handleCameraChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
103
- setSelectedCamera(e.target.value);
104
- setPage(0);
105
- }, []);
 
 
 
106
 
107
  if (loading || !data) {
108
  return (
109
  <div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
110
  <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
111
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
112
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
 
 
 
 
 
 
 
 
 
 
 
113
  </svg>
114
  Loading episode frames…
115
  </div>
@@ -117,14 +166,25 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
117
  }
118
 
119
  const allFrames = data.framesByCamera[selectedCamera] ?? [];
120
- const frames = flaggedOnly ? allFrames.filter(f => flagged.has(f.episodeIndex)) : allFrames;
 
 
121
 
122
  if (frames.length === 0) {
123
  return (
124
  <div className="text-center py-8 space-y-2">
125
- <p className="text-slate-500 italic">{flaggedOnly ? "No flagged episodes to show." : "No episode frames available."}</p>
 
 
 
 
126
  {flaggedOnly && onFlaggedOnlyChange && (
127
- <button onClick={() => onFlaggedOnlyChange(false)} className="text-xs text-orange-400 hover:text-orange-300 underline">Show all episodes</button>
 
 
 
 
 
128
  )}
129
  </div>
130
  );
@@ -136,7 +196,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
136
  return (
137
  <div className="max-w-7xl mx-auto py-6 space-y-5">
138
  <p className="text-sm text-slate-500">
139
- Use first/last frame views to spot episodes with bad end states or other anomalies. Hover over a thumbnail and click the flag icon to mark episodes with wrong outcomes for review.
 
 
140
  </p>
141
 
142
  {/* Controls row */}
@@ -150,7 +212,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
150
  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"
151
  >
152
  {data.cameras.map((cam) => (
153
- <option key={cam} value={cam}>{cam}</option>
 
 
154
  ))}
155
  </select>
156
  )}
@@ -158,16 +222,29 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
158
  {/* Flagged only toggle */}
159
  {flagCount > 0 && onFlaggedOnlyChange && (
160
  <button
161
- onClick={() => { onFlaggedOnlyChange(!flaggedOnly); setPage(0); }}
 
 
 
162
  className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
163
  flaggedOnly
164
  ? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
165
  : "text-slate-400 hover:text-slate-200 border border-slate-700"
166
  }`}
167
  >
168
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill={flaggedOnly ? "currentColor" : "none"}
169
- stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
170
- <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" y1="22" x2="4" y2="15" />
 
 
 
 
 
 
 
 
 
 
171
  </svg>
172
  Flagged only ({flagCount})
173
  </button>
@@ -175,7 +252,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
175
 
176
  {/* First / Last toggle */}
177
  <div className="flex items-center gap-3">
178
- <span className={`text-sm ${!showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}>
 
 
179
  First Frame
180
  </span>
181
  <button
@@ -187,7 +266,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
187
  className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${showLast ? "translate-x-[18px]" : "translate-x-[3px]"}`}
188
  />
189
  </button>
190
- <span className={`text-sm ${showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}>
 
 
191
  Last Frame
192
  </span>
193
  </div>
@@ -218,9 +299,16 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
218
  </div>
219
 
220
  {/* Adaptive grid — only current page's thumbnails are mounted */}
221
- <div className="grid gap-3" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}>
 
 
 
222
  {pageFrames.map((info) => (
223
- <FrameThumbnail key={`${selectedCamera}-${info.episodeIndex}`} info={info} showLast={showLast} />
 
 
 
 
224
  ))}
225
  </div>
226
  </div>
 
1
  "use client";
2
 
3
  import React, { useState, useEffect, useRef, useCallback } from "react";
4
+ import type {
5
+ EpisodeFrameInfo,
6
+ EpisodeFramesData,
7
+ } from "@/app/[org]/[dataset]/[episode]/fetch-data";
8
  import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
9
 
10
  const PAGE_SIZE = 48;
11
 
12
+ function FrameThumbnail({
13
+ info,
14
+ showLast,
15
+ }: {
16
+ info: EpisodeFrameInfo;
17
+ showLast: boolean;
18
+ }) {
19
  const containerRef = useRef<HTMLDivElement>(null);
20
  const videoRef = useRef<HTMLVideoElement>(null);
21
  const [inView, setInView] = useState(false);
 
24
  const el = containerRef.current;
25
  if (!el) return;
26
  const obs = new IntersectionObserver(
27
+ ([e]) => {
28
+ if (e.isIntersecting) {
29
+ setInView(true);
30
+ obs.disconnect();
31
+ }
32
+ },
33
  { rootMargin: "200px" },
34
  );
35
  obs.observe(el);
 
42
 
43
  const seek = () => {
44
  if (showLast) {
45
+ video.currentTime =
46
+ info.lastFrameTime ?? Math.max(0, video.duration - 0.05);
47
  } else {
48
  video.currentTime = info.firstFrameTime;
49
  }
 
77
  <button
78
  onClick={() => toggle(info.episodeIndex)}
79
  className={`absolute top-1 right-1 p-1 rounded transition-opacity ${
80
+ isFlagged
81
+ ? "opacity-100 text-orange-400"
82
+ : "opacity-0 group-hover:opacity-100 text-slate-400 hover:text-orange-400"
83
  }`}
84
  title={isFlagged ? "Unflag episode" : "Flag episode"}
85
  >
86
+ <svg
87
+ xmlns="http://www.w3.org/2000/svg"
88
+ width="14"
89
+ height="14"
90
+ viewBox="0 0 24 24"
91
+ fill={isFlagged ? "currentColor" : "none"}
92
+ stroke="currentColor"
93
+ strokeWidth="2"
94
+ strokeLinecap="round"
95
+ strokeLinejoin="round"
96
+ >
97
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
98
+ <line x1="4" y1="22" x2="4" y2="15" />
99
  </svg>
100
  </button>
101
  </div>
102
+ <p
103
+ className={`text-xs mt-1 tabular-nums ${isFlagged ? "text-orange-400" : "text-slate-400"}`}
104
+ >
105
+ ep {info.episodeIndex}
106
+ {isFlagged ? " ⚑" : ""}
107
  </p>
108
  </div>
109
  );
 
116
  onFlaggedOnlyChange?: (v: boolean) => void;
117
  }
118
 
119
+ export default function OverviewPanel({
120
+ data,
121
+ loading,
122
+ flaggedOnly = false,
123
+ onFlaggedOnlyChange,
124
+ }: OverviewPanelProps) {
125
  const { flagged, count: flagCount } = useFlaggedEpisodes();
126
  const [selectedCamera, setSelectedCamera] = useState<string>("");
127
  const [showLast, setShowLast] = useState(false);
 
134
  }
135
  }, [data, selectedCamera]);
136
 
137
+ const handleCameraChange = useCallback(
138
+ (e: React.ChangeEvent<HTMLSelectElement>) => {
139
+ setSelectedCamera(e.target.value);
140
+ setPage(0);
141
+ },
142
+ [],
143
+ );
144
 
145
  if (loading || !data) {
146
  return (
147
  <div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
148
  <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
149
+ <circle
150
+ className="opacity-25"
151
+ cx="12"
152
+ cy="12"
153
+ r="10"
154
+ stroke="currentColor"
155
+ strokeWidth="4"
156
+ />
157
+ <path
158
+ className="opacity-75"
159
+ fill="currentColor"
160
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
161
+ />
162
  </svg>
163
  Loading episode frames…
164
  </div>
 
166
  }
167
 
168
  const allFrames = data.framesByCamera[selectedCamera] ?? [];
169
+ const frames = flaggedOnly
170
+ ? allFrames.filter((f) => flagged.has(f.episodeIndex))
171
+ : allFrames;
172
 
173
  if (frames.length === 0) {
174
  return (
175
  <div className="text-center py-8 space-y-2">
176
+ <p className="text-slate-500 italic">
177
+ {flaggedOnly
178
+ ? "No flagged episodes to show."
179
+ : "No episode frames available."}
180
+ </p>
181
  {flaggedOnly && onFlaggedOnlyChange && (
182
+ <button
183
+ onClick={() => onFlaggedOnlyChange(false)}
184
+ className="text-xs text-orange-400 hover:text-orange-300 underline"
185
+ >
186
+ Show all episodes
187
+ </button>
188
  )}
189
  </div>
190
  );
 
196
  return (
197
  <div className="max-w-7xl mx-auto py-6 space-y-5">
198
  <p className="text-sm text-slate-500">
199
+ Use first/last frame views to spot episodes with bad end states or other
200
+ anomalies. Hover over a thumbnail and click the flag icon to mark
201
+ episodes with wrong outcomes for review.
202
  </p>
203
 
204
  {/* Controls row */}
 
212
  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"
213
  >
214
  {data.cameras.map((cam) => (
215
+ <option key={cam} value={cam}>
216
+ {cam}
217
+ </option>
218
  ))}
219
  </select>
220
  )}
 
222
  {/* Flagged only toggle */}
223
  {flagCount > 0 && onFlaggedOnlyChange && (
224
  <button
225
+ onClick={() => {
226
+ onFlaggedOnlyChange(!flaggedOnly);
227
+ setPage(0);
228
+ }}
229
  className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
230
  flaggedOnly
231
  ? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
232
  : "text-slate-400 hover:text-slate-200 border border-slate-700"
233
  }`}
234
  >
235
+ <svg
236
+ xmlns="http://www.w3.org/2000/svg"
237
+ width="12"
238
+ height="12"
239
+ viewBox="0 0 24 24"
240
+ fill={flaggedOnly ? "currentColor" : "none"}
241
+ stroke="currentColor"
242
+ strokeWidth="2"
243
+ strokeLinecap="round"
244
+ strokeLinejoin="round"
245
+ >
246
+ <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
247
+ <line x1="4" y1="22" x2="4" y2="15" />
248
  </svg>
249
  Flagged only ({flagCount})
250
  </button>
 
252
 
253
  {/* First / Last toggle */}
254
  <div className="flex items-center gap-3">
255
+ <span
256
+ className={`text-sm ${!showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}
257
+ >
258
  First Frame
259
  </span>
260
  <button
 
266
  className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${showLast ? "translate-x-[18px]" : "translate-x-[3px]"}`}
267
  />
268
  </button>
269
+ <span
270
+ className={`text-sm ${showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}
271
+ >
272
  Last Frame
273
  </span>
274
  </div>
 
299
  </div>
300
 
301
  {/* Adaptive grid — only current page's thumbnails are mounted */}
302
+ <div
303
+ className="grid gap-3"
304
+ style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}
305
+ >
306
  {pageFrames.map((info) => (
307
+ <FrameThumbnail
308
+ key={`${selectedCamera}-${info.episodeIndex}`}
309
+ info={info}
310
+ showLast={showLast}
311
+ />
312
  ))}
313
  </div>
314
  </div>
src/components/side-nav.tsx CHANGED
@@ -70,7 +70,10 @@ const Sidebar: React.FC<SidebarProps> = ({
70
  <div className="ml-2 mt-1">
71
  <ul>
72
  {displayEpisodes.map((episode) => (
73
- <li key={episode} className="mt-0.5 font-mono text-sm flex items-center gap-1">
 
 
 
74
  <Link
75
  href={`./episode_${episode}`}
76
  className={`underline ${episode === episodeId ? "-ml-1 font-bold" : ""}`}
 
70
  <div className="ml-2 mt-1">
71
  <ul>
72
  {displayEpisodes.map((episode) => (
73
+ <li
74
+ key={episode}
75
+ className="mt-0.5 font-mono text-sm flex items-center gap-1"
76
+ >
77
  <Link
78
  href={`./episode_${episode}`}
79
  className={`underline ${episode === episodeId ? "-ml-1 font-bold" : ""}`}
src/components/simple-videos-player.tsx CHANGED
@@ -80,13 +80,13 @@ export const SimpleVideosPlayer = ({
80
  video.currentTime = info.segmentStart || 0;
81
  checkReady();
82
  };
83
-
84
- video.addEventListener('timeupdate', handleTimeUpdate);
85
- video.addEventListener('loadeddata', handleLoadedData);
86
-
87
  videoEventCleanup.set(video, () => {
88
- video.removeEventListener('timeupdate', handleTimeUpdate);
89
- video.removeEventListener('loadeddata', handleLoadedData);
90
  });
91
  } else {
92
  // For non-segmented videos, handle end of video
@@ -96,12 +96,12 @@ export const SimpleVideosPlayer = ({
96
  setCurrentTime(0);
97
  }
98
  };
99
-
100
- video.addEventListener('ended', handleEnded);
101
- video.addEventListener('canplaythrough', checkReady, { once: true });
102
-
103
  videoEventCleanup.set(video, () => {
104
- video.removeEventListener('ended', handleEnded);
105
  });
106
  }
107
  }
@@ -150,8 +150,9 @@ export const SimpleVideosPlayer = ({
150
  useEffect(() => {
151
  if (!videosReady) return;
152
 
153
- const isExternalSeek = Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
154
-
 
155
  videoRefs.current.forEach((video, index) => {
156
  if (!video) return;
157
  if (hiddenVideos.includes(videosInfo[index].filename)) return;
@@ -164,7 +165,7 @@ export const SimpleVideosPlayer = ({
164
  if (info.isSegmented) {
165
  targetTime = (info.segmentStart || 0) + currentTime;
166
  }
167
-
168
  if (Math.abs(video.currentTime - targetTime) > 0.2) {
169
  video.currentTime = targetTime;
170
  }
@@ -280,7 +281,9 @@ export const SimpleVideosPlayer = ({
280
  </span>
281
  </p>
282
  <video
283
- ref={(el: HTMLVideoElement | null) => { videoRefs.current[idx] = el; }}
 
 
284
  className={`w-full object-contain ${
285
  isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
286
  }`}
 
80
  video.currentTime = info.segmentStart || 0;
81
  checkReady();
82
  };
83
+
84
+ video.addEventListener("timeupdate", handleTimeUpdate);
85
+ video.addEventListener("loadeddata", handleLoadedData);
86
+
87
  videoEventCleanup.set(video, () => {
88
+ video.removeEventListener("timeupdate", handleTimeUpdate);
89
+ video.removeEventListener("loadeddata", handleLoadedData);
90
  });
91
  } else {
92
  // For non-segmented videos, handle end of video
 
96
  setCurrentTime(0);
97
  }
98
  };
99
+
100
+ video.addEventListener("ended", handleEnded);
101
+ video.addEventListener("canplaythrough", checkReady, { once: true });
102
+
103
  videoEventCleanup.set(video, () => {
104
+ video.removeEventListener("ended", handleEnded);
105
  });
106
  }
107
  }
 
150
  useEffect(() => {
151
  if (!videosReady) return;
152
 
153
+ const isExternalSeek =
154
+ Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
155
+
156
  videoRefs.current.forEach((video, index) => {
157
  if (!video) return;
158
  if (hiddenVideos.includes(videosInfo[index].filename)) return;
 
165
  if (info.isSegmented) {
166
  targetTime = (info.segmentStart || 0) + currentTime;
167
  }
168
+
169
  if (Math.abs(video.currentTime - targetTime) > 0.2) {
170
  video.currentTime = targetTime;
171
  }
 
281
  </span>
282
  </p>
283
  <video
284
+ ref={(el: HTMLVideoElement | null) => {
285
+ videoRefs.current[idx] = el;
286
+ }}
287
  className={`w-full object-contain ${
288
  isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
289
  }`}
src/components/stats-panel.tsx CHANGED
@@ -22,14 +22,19 @@ function formatTotalTime(totalFrames: number, fps: number): string {
22
  }
23
 
24
  /** SVG bar chart for the episode-length histogram */
25
- const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number }[] }> = ({ data }) => {
 
 
26
  if (data.length === 0) return null;
27
  const maxCount = Math.max(...data.map((d) => d.count));
28
  if (maxCount === 0) return null;
29
 
30
  const totalWidth = 560;
31
  const gap = Math.max(1, Math.min(3, Math.floor(60 / data.length)));
32
- const barWidth = Math.max(4, Math.floor((totalWidth - gap * data.length) / data.length));
 
 
 
33
  const chartHeight = 150;
34
  const labelHeight = 30;
35
  const topPad = 16;
@@ -38,7 +43,12 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
38
 
39
  return (
40
  <div className="overflow-x-auto">
41
- <svg width={svgWidth} height={topPad + chartHeight + labelHeight} className="block" aria-label="Episode length distribution histogram">
 
 
 
 
 
42
  {data.map((bin, i) => {
43
  const barH = Math.max(1, (bin.count / maxCount) * chartHeight);
44
  const x = i * (barWidth + gap);
@@ -46,9 +56,22 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
46
  return (
47
  <g key={i}>
48
  <title>{`${bin.binLabel}: ${bin.count} episode${bin.count !== 1 ? "s" : ""}`}</title>
49
- <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)} />
 
 
 
 
 
 
 
50
  {bin.count > 0 && barWidth >= 8 && (
51
- <text x={x + barWidth / 2} y={y - 3} textAnchor="middle" className="fill-slate-400" fontSize={Math.min(10, barWidth - 1)}>
 
 
 
 
 
 
52
  {bin.count}
53
  </text>
54
  )}
@@ -61,7 +84,14 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
61
  if (!isFirst && !isLast && idx % labelStep !== 0) return null;
62
  const label = bin.binLabel.split("–")[0];
63
  return (
64
- <text key={idx} x={idx * (barWidth + gap) + barWidth / 2} y={topPad + chartHeight + 14} textAnchor="middle" className="fill-slate-400" fontSize={9}>
 
 
 
 
 
 
 
65
  {label}s
66
  </text>
67
  );
@@ -71,7 +101,10 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
71
  );
72
  };
73
 
74
- const Card: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
 
 
 
75
  <div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
76
  <p className="text-xs text-slate-400 uppercase tracking-wide">{label}</p>
77
  <p className="text-xl font-bold tabular-nums mt-1">{value}</p>
@@ -88,7 +121,12 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
88
  return (
89
  <div className="max-w-4xl mx-auto py-6 space-y-8">
90
  <div>
91
- <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>
 
 
 
 
 
92
  </div>
93
 
94
  {/* Overview cards */}
@@ -99,21 +137,39 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
99
  </div>
100
 
101
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
102
- <Card label="Total Frames" value={datasetInfo.total_frames.toLocaleString()} />
103
- <Card label="Total Episodes" value={datasetInfo.total_episodes.toLocaleString()} />
 
 
 
 
 
 
104
  <Card label="FPS" value={datasetInfo.fps} />
105
- <Card label="Total Recording Time" value={formatTotalTime(datasetInfo.total_frames, datasetInfo.fps)} />
 
 
 
106
  </div>
107
 
108
  {/* Camera resolutions */}
109
  {datasetInfo.cameras.length > 0 && (
110
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
111
- <h3 className="text-sm font-semibold text-slate-200 mb-3">Camera Resolutions</h3>
 
 
112
  <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
113
  {datasetInfo.cameras.map((cam: CameraInfo) => (
114
  <div key={cam.name} className="bg-slate-900/50 rounded-md p-3">
115
- <p className="text-xs text-slate-400 mb-1 truncate" title={cam.name}>{cam.name}</p>
116
- <p className="text-base font-bold tabular-nums">{cam.width}×{cam.height}</p>
 
 
 
 
 
 
 
117
  </div>
118
  ))}
119
  </div>
@@ -124,8 +180,19 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
124
  {loading && (
125
  <div className="flex items-center gap-2 text-slate-400 text-sm py-4">
126
  <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
127
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
128
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
 
 
 
 
 
 
 
 
 
 
 
129
  </svg>
130
  Computing episode statistics…
131
  </div>
@@ -135,10 +202,18 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
135
  {els && (
136
  <>
137
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
138
- <h3 className="text-sm font-semibold text-slate-200 mb-4">Episode Lengths</h3>
 
 
139
  <div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
140
- <Card label="Shortest" value={`${els.shortestEpisodes[0]?.lengthSeconds ?? "–"}s`} />
141
- <Card label="Longest" value={`${els.longestEpisodes[els.longestEpisodes.length - 1]?.lengthSeconds ?? "–"}s`} />
 
 
 
 
 
 
142
  <Card label="Mean" value={`${els.meanEpisodeLength}s`} />
143
  <Card label="Median" value={`${els.medianEpisodeLength}s`} />
144
  <Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
@@ -150,13 +225,13 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
150
  <h3 className="text-sm font-semibold text-slate-200 mb-4">
151
  Episode Length Distribution
152
  <span className="text-xs text-slate-500 ml-2 font-normal">
153
- {els.episodeLengthHistogram.length} bin{els.episodeLengthHistogram.length !== 1 ? "s" : ""}
 
154
  </span>
155
  </h3>
156
  <EpisodeLengthHistogram data={els.episodeLengthHistogram} />
157
  </div>
158
  )}
159
-
160
  </>
161
  )}
162
  </div>
 
22
  }
23
 
24
  /** SVG bar chart for the episode-length histogram */
25
+ const EpisodeLengthHistogram: React.FC<{
26
+ data: { binLabel: string; count: number }[];
27
+ }> = ({ data }) => {
28
  if (data.length === 0) return null;
29
  const maxCount = Math.max(...data.map((d) => d.count));
30
  if (maxCount === 0) return null;
31
 
32
  const totalWidth = 560;
33
  const gap = Math.max(1, Math.min(3, Math.floor(60 / data.length)));
34
+ const barWidth = Math.max(
35
+ 4,
36
+ Math.floor((totalWidth - gap * data.length) / data.length),
37
+ );
38
  const chartHeight = 150;
39
  const labelHeight = 30;
40
  const topPad = 16;
 
43
 
44
  return (
45
  <div className="overflow-x-auto">
46
+ <svg
47
+ width={svgWidth}
48
+ height={topPad + chartHeight + labelHeight}
49
+ className="block"
50
+ aria-label="Episode length distribution histogram"
51
+ >
52
  {data.map((bin, i) => {
53
  const barH = Math.max(1, (bin.count / maxCount) * chartHeight);
54
  const x = i * (barWidth + gap);
 
56
  return (
57
  <g key={i}>
58
  <title>{`${bin.binLabel}: ${bin.count} episode${bin.count !== 1 ? "s" : ""}`}</title>
59
+ <rect
60
+ x={x}
61
+ y={y}
62
+ width={barWidth}
63
+ height={barH}
64
+ className="fill-orange-500/80 hover:fill-orange-400 transition-colors"
65
+ rx={Math.min(2, barWidth / 4)}
66
+ />
67
  {bin.count > 0 && barWidth >= 8 && (
68
+ <text
69
+ x={x + barWidth / 2}
70
+ y={y - 3}
71
+ textAnchor="middle"
72
+ className="fill-slate-400"
73
+ fontSize={Math.min(10, barWidth - 1)}
74
+ >
75
  {bin.count}
76
  </text>
77
  )}
 
84
  if (!isFirst && !isLast && idx % labelStep !== 0) return null;
85
  const label = bin.binLabel.split("–")[0];
86
  return (
87
+ <text
88
+ key={idx}
89
+ x={idx * (barWidth + gap) + barWidth / 2}
90
+ y={topPad + chartHeight + 14}
91
+ textAnchor="middle"
92
+ className="fill-slate-400"
93
+ fontSize={9}
94
+ >
95
  {label}s
96
  </text>
97
  );
 
101
  );
102
  };
103
 
104
+ const Card: React.FC<{ label: string; value: string | number }> = ({
105
+ label,
106
+ value,
107
+ }) => (
108
  <div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
109
  <p className="text-xs text-slate-400 uppercase tracking-wide">{label}</p>
110
  <p className="text-xl font-bold tabular-nums mt-1">{value}</p>
 
121
  return (
122
  <div className="max-w-4xl mx-auto py-6 space-y-8">
123
  <div>
124
+ <h2 className="text-xl text-slate-100">
125
+ <span className="font-bold">Dataset Statistics:</span>{" "}
126
+ <span className="font-normal text-slate-400">
127
+ {datasetInfo.repoId}
128
+ </span>
129
+ </h2>
130
  </div>
131
 
132
  {/* Overview cards */}
 
137
  </div>
138
 
139
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
140
+ <Card
141
+ label="Total Frames"
142
+ value={datasetInfo.total_frames.toLocaleString()}
143
+ />
144
+ <Card
145
+ label="Total Episodes"
146
+ value={datasetInfo.total_episodes.toLocaleString()}
147
+ />
148
  <Card label="FPS" value={datasetInfo.fps} />
149
+ <Card
150
+ label="Total Recording Time"
151
+ value={formatTotalTime(datasetInfo.total_frames, datasetInfo.fps)}
152
+ />
153
  </div>
154
 
155
  {/* Camera resolutions */}
156
  {datasetInfo.cameras.length > 0 && (
157
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
158
+ <h3 className="text-sm font-semibold text-slate-200 mb-3">
159
+ Camera Resolutions
160
+ </h3>
161
  <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
162
  {datasetInfo.cameras.map((cam: CameraInfo) => (
163
  <div key={cam.name} className="bg-slate-900/50 rounded-md p-3">
164
+ <p
165
+ className="text-xs text-slate-400 mb-1 truncate"
166
+ title={cam.name}
167
+ >
168
+ {cam.name}
169
+ </p>
170
+ <p className="text-base font-bold tabular-nums">
171
+ {cam.width}×{cam.height}
172
+ </p>
173
  </div>
174
  ))}
175
  </div>
 
180
  {loading && (
181
  <div className="flex items-center gap-2 text-slate-400 text-sm py-4">
182
  <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
183
+ <circle
184
+ className="opacity-25"
185
+ cx="12"
186
+ cy="12"
187
+ r="10"
188
+ stroke="currentColor"
189
+ strokeWidth="4"
190
+ />
191
+ <path
192
+ className="opacity-75"
193
+ fill="currentColor"
194
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
195
+ />
196
  </svg>
197
  Computing episode statistics…
198
  </div>
 
202
  {els && (
203
  <>
204
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
205
+ <h3 className="text-sm font-semibold text-slate-200 mb-4">
206
+ Episode Lengths
207
+ </h3>
208
  <div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
209
+ <Card
210
+ label="Shortest"
211
+ value={`${els.shortestEpisodes[0]?.lengthSeconds ?? "–"}s`}
212
+ />
213
+ <Card
214
+ label="Longest"
215
+ value={`${els.longestEpisodes[els.longestEpisodes.length - 1]?.lengthSeconds ?? "–"}s`}
216
+ />
217
  <Card label="Mean" value={`${els.meanEpisodeLength}s`} />
218
  <Card label="Median" value={`${els.medianEpisodeLength}s`} />
219
  <Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
 
225
  <h3 className="text-sm font-semibold text-slate-200 mb-4">
226
  Episode Length Distribution
227
  <span className="text-xs text-slate-500 ml-2 font-normal">
228
+ {els.episodeLengthHistogram.length} bin
229
+ {els.episodeLengthHistogram.length !== 1 ? "s" : ""}
230
  </span>
231
  </h3>
232
  <EpisodeLengthHistogram data={els.episodeLengthHistogram} />
233
  </div>
234
  )}
 
235
  </>
236
  )}
237
  </div>
src/components/urdf-viewer.tsx CHANGED
@@ -1,6 +1,12 @@
1
  "use client";
2
 
3
- import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
 
 
 
 
 
 
4
  import { Canvas, useThree, useFrame } from "@react-three/fiber";
5
  import { OrbitControls, Grid, Html } from "@react-three/drei";
6
  import * as THREE from "three";
@@ -50,23 +56,34 @@ function groupColumnsByPrefix(keys: string[]): Record<string, string[]> {
50
  return groups;
51
  }
52
 
53
- function autoMatchJoints(urdfJointNames: string[], columnKeys: string[]): Record<string, string> {
 
 
 
54
  const mapping: Record<string, string> = {};
55
- const suffixes = columnKeys.map((k) => (k.split(SERIES_DELIM).pop()?.trim() ?? k).toLowerCase());
 
 
56
 
57
  for (const jointName of urdfJointNames) {
58
  const lower = jointName.toLowerCase();
59
 
60
  // Exact match on column suffix
61
  const exactIdx = suffixes.findIndex((s) => s === lower);
62
- if (exactIdx >= 0) { mapping[jointName] = columnKeys[exactIdx]; continue; }
 
 
 
63
 
64
  // OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+)
65
  const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
66
  if (armMatch) {
67
  const pattern = `${armMatch[1]}_joint_${armMatch[2]}`;
68
  const idx = suffixes.findIndex((s) => s.includes(pattern));
69
- if (idx >= 0) { mapping[jointName] = columnKeys[idx]; continue; }
 
 
 
70
  }
71
 
72
  // OpenArm: openarm_(left|right)_finger_joint1 → (left|right)_gripper
@@ -74,7 +91,10 @@ function autoMatchJoints(urdfJointNames: string[], columnKeys: string[]): Record
74
  if (fingerMatch) {
75
  const pattern = `${fingerMatch[1]}_gripper`;
76
  const idx = suffixes.findIndex((s) => s.includes(pattern));
77
- if (idx >= 0) { mapping[jointName] = columnKeys[idx]; continue; }
 
 
 
78
  }
79
 
80
  // finger_joint2 is a mimic joint — skip
@@ -87,7 +107,12 @@ function autoMatchJoints(urdfJointNames: string[], columnKeys: string[]): Record
87
  return mapping;
88
  }
89
 
90
- const SINGLE_ARM_TIP_NAMES = ["gripper_frame_link", "gripperframe", "gripper_link", "gripper"];
 
 
 
 
 
91
  const DUAL_ARM_TIP_NAMES = ["openarm_left_hand_tcp", "openarm_right_hand_tcp"];
92
  const TRAIL_DURATION = 1.0;
93
  const TRAIL_COLORS = [new THREE.Color("#ff6600"), new THREE.Color("#00aaff")];
@@ -95,7 +120,12 @@ const MAX_TRAIL_POINTS = 300;
95
 
96
  // ─── Robot scene (imperative, inside Canvas) ───
97
  function RobotScene({
98
- urdfUrl, jointValues, onJointsLoaded, trailEnabled, trailResetKey, scale,
 
 
 
 
 
99
  }: {
100
  urdfUrl: string;
101
  jointValues: Record<string, number>;
@@ -110,7 +140,12 @@ function RobotScene({
110
  const [loading, setLoading] = useState(true);
111
  const [error, setError] = useState<string | null>(null);
112
 
113
- type TrailState = { positions: Float32Array; colors: Float32Array; times: number[]; count: number };
 
 
 
 
 
114
  const trailsRef = useRef<TrailState[]>([]);
115
  const linesRef = useRef<Line2[]>([]);
116
  const trailMatsRef = useRef<LineMaterial[]>([]);
@@ -118,36 +153,56 @@ function RobotScene({
118
 
119
  // Reset trails when episode changes
120
  useEffect(() => {
121
- for (const t of trailsRef.current) { t.count = 0; t.times = []; }
 
 
 
122
  for (const l of linesRef.current) l.visible = false;
123
  }, [trailResetKey]);
124
 
125
  // Create/destroy trail Line2 objects when tip count changes
126
- const ensureTrails = useCallback((count: number) => {
127
- if (trailCountRef.current === count) return;
128
- // Remove old
129
- for (const l of linesRef.current) { scene.remove(l); l.geometry.dispose(); }
130
- for (const m of trailMatsRef.current) m.dispose();
131
- // Create new
132
- const trails: TrailState[] = [];
133
- const lines: Line2[] = [];
134
- const mats: LineMaterial[] = [];
135
- for (let i = 0; i < count; i++) {
136
- trails.push({ positions: new Float32Array(MAX_TRAIL_POINTS * 3), colors: new Float32Array(MAX_TRAIL_POINTS * 3), times: [], count: 0 });
137
- const mat = new LineMaterial({ color: 0xffffff, linewidth: 4, vertexColors: true, transparent: true, worldUnits: false });
138
- mat.resolution.set(window.innerWidth, window.innerHeight);
139
- mats.push(mat);
140
- const line = new Line2(new LineGeometry(), mat);
141
- line.frustumCulled = false;
142
- line.visible = false;
143
- lines.push(line);
144
- scene.add(line);
145
- }
146
- trailsRef.current = trails;
147
- linesRef.current = lines;
148
- trailMatsRef.current = mats;
149
- trailCountRef.current = count;
150
- }, [scene]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  useEffect(() => {
153
  setLoading(true);
@@ -159,22 +214,27 @@ function RobotScene({
159
  // DAE (Collada) files — load with embedded materials
160
  if (url.endsWith(".dae")) {
161
  const colladaLoader = new ColladaLoader(mgr);
162
- colladaLoader.load(url, (collada) => {
163
- if (isOpenArm) {
164
- collada.scene.traverse((child) => {
165
- if (child instanceof THREE.Mesh && child.material) {
166
- const mat = child.material as THREE.MeshStandardMaterial;
167
- if (mat.side !== undefined) mat.side = THREE.DoubleSide;
168
- if (mat.color) {
169
- const hsl = { h: 0, s: 0, l: 0 };
170
- mat.color.getHSL(hsl);
171
- if (hsl.l > 0.7) mat.color.setHSL(hsl.h, hsl.s, 0.55);
 
 
 
172
  }
173
- }
174
- });
175
- }
176
- onLoad(collada.scene);
177
- }, undefined, (err) => onLoad(new THREE.Object3D(), err as Error));
 
 
178
  return;
179
  }
180
  // STL files — apply custom materials
@@ -186,12 +246,20 @@ function RobotScene({
186
  let metalness = 0.1;
187
  let roughness = 0.6;
188
  if (url.includes("sts3215")) {
189
- color = "#1a1a1a"; metalness = 0.7; roughness = 0.3;
 
 
190
  } else if (isOpenArm) {
191
  color = url.includes("body_link0") ? "#3a3a4a" : "#f5f5f5";
192
- metalness = 0.15; roughness = 0.6;
 
193
  }
194
- const material = new THREE.MeshStandardMaterial({ color, metalness, roughness, side: isOpenArm ? THREE.DoubleSide : THREE.FrontSide });
 
 
 
 
 
195
  onLoad(new THREE.Mesh(geometry, material));
196
  },
197
  undefined,
@@ -203,7 +271,9 @@ function RobotScene({
203
  (robot) => {
204
  robotRef.current = robot;
205
  robot.rotateOnAxis(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
206
- robot.traverse((c) => { c.castShadow = true; });
 
 
207
  robot.updateMatrixWorld(true);
208
  robot.scale.set(scale, scale, scale);
209
  scene.add(robot);
@@ -218,16 +288,28 @@ function RobotScene({
218
  ensureTrails(tips.length);
219
 
220
  const movable = Object.values(robot.joints)
221
- .filter((j) => j.jointType === "revolute" || j.jointType === "continuous" || j.jointType === "prismatic")
 
 
 
 
 
222
  .map((j) => j.name);
223
  onJointsLoaded(movable);
224
  setLoading(false);
225
  },
226
  undefined,
227
- (err) => { console.error("Error loading URDF:", err); setError(String(err)); setLoading(false); },
 
 
 
 
228
  );
229
  return () => {
230
- if (robotRef.current) { scene.remove(robotRef.current); robotRef.current = null; }
 
 
 
231
  tipLinksRef.current = [];
232
  };
233
  }, [urdfUrl, scale, scene, onJointsLoaded, ensureTrails]);
@@ -281,9 +363,14 @@ function RobotScene({
281
  trail.colors[i * 3 + 2] = trailColor.b * t;
282
  }
283
 
284
- if (trail.count < 2) { line.visible = false; continue; }
 
 
 
285
  const geo = new LineGeometry();
286
- geo.setPositions(Array.from(trail.positions.subarray(0, trail.count * 3)));
 
 
287
  geo.setColors(Array.from(trail.colors.subarray(0, trail.count * 3)));
288
  line.geometry.dispose();
289
  line.geometry = geo;
@@ -292,16 +379,32 @@ function RobotScene({
292
  }
293
  });
294
 
295
- if (loading) return <Html center><span className="text-white text-lg">Loading robot…</span></Html>;
296
- if (error) return <Html center><span className="text-red-400">Failed to load URDF</span></Html>;
 
 
 
 
 
 
 
 
 
 
297
  return null;
298
  }
299
 
300
  // ─── Playback ticker ───
301
  function PlaybackDriver({
302
- playing, fps, totalFrames, frameRef, setFrame,
 
 
 
 
303
  }: {
304
- playing: boolean; fps: number; totalFrames: number;
 
 
305
  frameRef: React.MutableRefObject<number>;
306
  setFrame: React.Dispatch<React.SetStateAction<number>>;
307
  }) {
@@ -347,7 +450,10 @@ export default function URDFViewer({
347
  }) {
348
  const { datasetInfo, episodes } = data;
349
  const fps = datasetInfo.fps || 30;
350
- const robotConfig = useMemo(() => getRobotConfig(datasetInfo.robot_type), [datasetInfo.robot_type]);
 
 
 
351
  const { urdfUrl, scale } = robotConfig;
352
 
353
  // Episode selection & chart data
@@ -358,33 +464,39 @@ export default function URDFViewer({
358
  [data.episodeId]: data.flatChartData,
359
  });
360
 
361
- const handleEpisodeChange = useCallback((epId: number) => {
362
- setSelectedEpisode(epId);
363
- setFrame(0);
364
- frameRef.current = 0;
365
- setPlaying(false);
 
366
 
367
- if (chartDataCache.current[epId]) {
368
- setChartData(chartDataCache.current[epId]);
369
- return;
370
- }
371
 
372
- if (!org || !dataset) return;
373
- setEpisodeLoading(true);
374
- fetchEpisodeChartData(org, dataset, epId)
375
- .then((result) => {
376
- chartDataCache.current[epId] = result;
377
- setChartData(result);
378
- })
379
- .catch((err) => console.error("Failed to load episode:", err))
380
- .finally(() => setEpisodeLoading(false));
381
- }, [org, dataset]);
 
 
382
 
383
  const totalFrames = chartData.length;
384
 
385
  // URDF joint names
386
  const [urdfJointNames, setUrdfJointNames] = useState<string[]>([]);
387
- const onJointsLoaded = useCallback((names: string[]) => setUrdfJointNames(names), []);
 
 
 
388
 
389
  // Feature groups
390
  const columnGroups = useMemo(() => {
@@ -397,7 +509,8 @@ export default function URDFViewer({
397
  () =>
398
  groupNames.find((g) => g.toLowerCase().includes("state")) ??
399
  groupNames.find((g) => g.toLowerCase().includes("action")) ??
400
- groupNames[0] ?? "",
 
401
  [groupNames],
402
  );
403
 
@@ -422,15 +535,19 @@ export default function URDFViewer({
422
  const [playing, setPlaying] = useState(false);
423
  const frameRef = useRef(0);
424
 
425
- const handleFrameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
426
- const f = parseInt(e.target.value);
427
- setFrame(f);
428
- frameRef.current = f;
429
- }, []);
 
 
 
430
 
431
  // Filter out mimic joints (finger_joint2) from the UI list
432
  const displayJointNames = useMemo(
433
- () => urdfJointNames.filter((n) => !n.toLowerCase().includes("finger_joint2")),
 
434
  [urdfJointNames],
435
  );
436
 
@@ -441,10 +558,14 @@ export default function URDFViewer({
441
  if (!jn.toLowerCase().includes("finger_joint1")) continue;
442
  const col = mapping[jn];
443
  if (!col) continue;
444
- let min = Infinity, max = -Infinity;
 
445
  for (const row of chartData) {
446
  const v = row[col];
447
- if (typeof v === "number") { if (v < min) min = v; if (v > max) max = v; }
 
 
 
448
  }
449
  if (min < max) ranges[jn] = { min, max };
450
  }
@@ -481,7 +602,9 @@ export default function URDFViewer({
481
  }
482
 
483
  const converted = detectAndConvert(revoluteValues);
484
- revoluteNames.forEach((n, i) => { values[n] = converted[i]; });
 
 
485
 
486
  // Copy finger_joint1 → finger_joint2 (mimic joints)
487
  for (const jn of urdfJointNames) {
@@ -497,7 +620,11 @@ export default function URDFViewer({
497
  const totalTime = (totalFrames / fps).toFixed(2);
498
 
499
  if (data.flatChartData.length === 0) {
500
- return <div className="text-slate-400 p-8 text-center">No trajectory data available.</div>;
 
 
 
 
501
  }
502
 
503
  return (
@@ -506,22 +633,50 @@ export default function URDFViewer({
506
  <div className="flex-1 min-h-0 bg-slate-950 rounded-lg overflow-hidden border border-slate-700 relative">
507
  {episodeLoading && (
508
  <div className="absolute inset-0 z-10 flex items-center justify-center bg-slate-950/70">
509
- <span className="text-white text-lg animate-pulse">Loading episode {selectedEpisode}…</span>
 
 
510
  </div>
511
  )}
512
- <Canvas camera={{ position: [0.3 * scale, 0.25 * scale, 0.3 * scale], fov: 45, near: 0.01, far: 100 }}>
 
 
 
 
 
 
 
513
  <ambientLight intensity={0.7} />
514
  <directionalLight position={[3, 5, 4]} intensity={1.5} />
515
  <directionalLight position={[-2, 3, -2]} intensity={0.6} />
516
  <hemisphereLight args={["#b1e1ff", "#666666", 0.5]} />
517
- <RobotScene urdfUrl={urdfUrl} jointValues={jointValues} onJointsLoaded={onJointsLoaded} trailEnabled={trailEnabled} trailResetKey={selectedEpisode} scale={scale} />
 
 
 
 
 
 
 
518
  <Grid
519
- args={[10, 10]} cellSize={0.2} cellThickness={0.5} cellColor="#334155"
520
- sectionSize={1} sectionThickness={1} sectionColor="#475569"
521
- fadeDistance={10} position={[0, 0, 0]}
 
 
 
 
 
 
522
  />
523
  <OrbitControls target={[0, 0.8, 0]} />
524
- <PlaybackDriver playing={playing} fps={fps} totalFrames={totalFrames} frameRef={frameRef} setFrame={setFrame} />
 
 
 
 
 
 
525
  </Canvas>
526
  </div>
527
 
@@ -532,35 +687,55 @@ export default function URDFViewer({
532
  {/* Episode selector */}
533
  <div className="flex items-center gap-1.5 shrink-0">
534
  <button
535
- onClick={() => { if (selectedEpisode > episodes[0]) handleEpisodeChange(selectedEpisode - 1); }}
 
 
 
536
  disabled={selectedEpisode <= episodes[0]}
537
  className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
538
- >◀</button>
 
 
539
  <select
540
  value={selectedEpisode}
541
  onChange={(e) => handleEpisodeChange(Number(e.target.value))}
542
  className="bg-slate-900 text-slate-200 text-xs rounded px-1.5 py-1 border border-slate-600 w-28"
543
  >
544
  {episodes.map((ep) => (
545
- <option key={ep} value={ep}>Episode {ep}</option>
 
 
546
  ))}
547
  </select>
548
  <button
549
- onClick={() => { if (selectedEpisode < episodes[episodes.length - 1]) handleEpisodeChange(selectedEpisode + 1); }}
 
 
 
550
  disabled={selectedEpisode >= episodes[episodes.length - 1]}
551
  className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
552
- >▶</button>
 
 
553
  </div>
554
 
555
  {/* Play/Pause */}
556
  <button
557
- onClick={() => { setPlaying(!playing); if (!playing) frameRef.current = frame; }}
 
 
 
558
  className="w-8 h-8 flex items-center justify-center rounded bg-orange-600 hover:bg-orange-500 text-white transition-colors shrink-0"
559
  >
560
  {playing ? (
561
- <svg width="12" height="14" viewBox="0 0 12 14"><rect x="1" y="1" width="3" height="12" fill="white" /><rect x="8" y="1" width="3" height="12" fill="white" /></svg>
 
 
 
562
  ) : (
563
- <svg width="12" height="14" viewBox="0 0 12 14"><polygon points="2,1 11,7 2,13" fill="white" /></svg>
 
 
564
  )}
565
  </button>
566
 
@@ -568,16 +743,30 @@ export default function URDFViewer({
568
  <button
569
  onClick={() => setTrailEnabled((v) => !v)}
570
  className={`px-2 h-8 text-xs rounded transition-colors shrink-0 ${
571
- trailEnabled ? "bg-orange-600/30 text-orange-400 border border-orange-500" : "bg-slate-700 text-slate-400 border border-slate-600"
 
 
572
  }`}
573
  title={trailEnabled ? "Hide trail" : "Show trail"}
574
- >Trail</button>
 
 
575
 
576
  {/* Scrubber */}
577
- <input type="range" min={0} max={Math.max(totalFrames - 1, 0)} value={frame}
578
- onChange={handleFrameChange} className="flex-1 h-1.5 accent-orange-500 cursor-pointer" />
579
- <span className="text-xs text-slate-400 tabular-nums w-28 text-right shrink-0">{currentTime}s / {totalTime}s</span>
580
- <span className="text-xs text-slate-500 tabular-nums w-20 text-right shrink-0">F {frame}/{Math.max(totalFrames - 1, 0)}</span>
 
 
 
 
 
 
 
 
 
 
581
  </div>
582
 
583
  {/* Collapsible joint mapping */}
@@ -585,9 +774,16 @@ export default function URDFViewer({
585
  onClick={() => setShowMapping((v) => !v)}
586
  className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
587
  >
588
- <span className={`transition-transform ${showMapping ? "rotate-90" : ""}`}>▶</span>
 
 
 
 
589
  Joint Mapping
590
- <span className="text-slate-600">({Object.keys(mapping).filter((k) => mapping[k]).length}/{displayJointNames.length} mapped)</span>
 
 
 
591
  </button>
592
 
593
  {showMapping && (
@@ -596,10 +792,17 @@ export default function URDFViewer({
596
  <label className="text-xs text-slate-400">Data source</label>
597
  <div className="flex gap-1 flex-wrap">
598
  {groupNames.map((name) => (
599
- <button key={name} onClick={() => setSelectedGroup(name)}
 
 
600
  className={`px-2 py-1 text-xs rounded transition-colors ${
601
- selectedGroup === name ? "bg-orange-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"
602
- }`}>{name}</button>
 
 
 
 
 
603
  ))}
604
  </div>
605
  </div>
@@ -610,28 +813,48 @@ export default function URDFViewer({
610
  <tr className="text-slate-500">
611
  <th className="text-left font-normal px-1">URDF Joint</th>
612
  <th className="text-left font-normal px-1">→</th>
613
- <th className="text-left font-normal px-1">Dataset Column</th>
 
 
614
  <th className="text-right font-normal px-1">Value</th>
615
  </tr>
616
  </thead>
617
  <tbody>
618
  {displayJointNames.map((jointName) => (
619
- <tr key={jointName} className="border-t border-slate-700/50">
620
- <td className="px-1 py-0.5 text-slate-300 font-mono">{jointName}</td>
 
 
 
 
 
621
  <td className="px-1 text-slate-600">→</td>
622
  <td className="px-1 py-0.5">
623
- <select value={mapping[jointName] ?? ""}
624
- onChange={(e) => setMapping((m) => ({ ...m, [jointName]: e.target.value }))}
625
- className="bg-slate-900 text-slate-200 text-xs rounded px-1 py-0.5 border border-slate-600 w-full max-w-[200px]">
 
 
 
 
 
 
 
626
  <option value="">-- unmapped --</option>
627
  {selectedColumns.map((col) => {
628
  const label = col.split(SERIES_DELIM).pop() ?? col;
629
- return <option key={col} value={col}>{label}</option>;
 
 
 
 
630
  })}
631
  </select>
632
  </td>
633
  <td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
634
- {jointValues[jointName] !== undefined ? jointValues[jointName].toFixed(3) : "—"}
 
 
635
  </td>
636
  </tr>
637
  ))}
 
1
  "use client";
2
 
3
+ import React, {
4
+ useState,
5
+ useEffect,
6
+ useRef,
7
+ useMemo,
8
+ useCallback,
9
+ } from "react";
10
  import { Canvas, useThree, useFrame } from "@react-three/fiber";
11
  import { OrbitControls, Grid, Html } from "@react-three/drei";
12
  import * as THREE from "three";
 
56
  return groups;
57
  }
58
 
59
+ function autoMatchJoints(
60
+ urdfJointNames: string[],
61
+ columnKeys: string[],
62
+ ): Record<string, string> {
63
  const mapping: Record<string, string> = {};
64
+ const suffixes = columnKeys.map((k) =>
65
+ (k.split(SERIES_DELIM).pop()?.trim() ?? k).toLowerCase(),
66
+ );
67
 
68
  for (const jointName of urdfJointNames) {
69
  const lower = jointName.toLowerCase();
70
 
71
  // Exact match on column suffix
72
  const exactIdx = suffixes.findIndex((s) => s === lower);
73
+ if (exactIdx >= 0) {
74
+ mapping[jointName] = columnKeys[exactIdx];
75
+ continue;
76
+ }
77
 
78
  // OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+)
79
  const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
80
  if (armMatch) {
81
  const pattern = `${armMatch[1]}_joint_${armMatch[2]}`;
82
  const idx = suffixes.findIndex((s) => s.includes(pattern));
83
+ if (idx >= 0) {
84
+ mapping[jointName] = columnKeys[idx];
85
+ continue;
86
+ }
87
  }
88
 
89
  // OpenArm: openarm_(left|right)_finger_joint1 → (left|right)_gripper
 
91
  if (fingerMatch) {
92
  const pattern = `${fingerMatch[1]}_gripper`;
93
  const idx = suffixes.findIndex((s) => s.includes(pattern));
94
+ if (idx >= 0) {
95
+ mapping[jointName] = columnKeys[idx];
96
+ continue;
97
+ }
98
  }
99
 
100
  // finger_joint2 is a mimic joint — skip
 
107
  return mapping;
108
  }
109
 
110
+ const SINGLE_ARM_TIP_NAMES = [
111
+ "gripper_frame_link",
112
+ "gripperframe",
113
+ "gripper_link",
114
+ "gripper",
115
+ ];
116
  const DUAL_ARM_TIP_NAMES = ["openarm_left_hand_tcp", "openarm_right_hand_tcp"];
117
  const TRAIL_DURATION = 1.0;
118
  const TRAIL_COLORS = [new THREE.Color("#ff6600"), new THREE.Color("#00aaff")];
 
120
 
121
  // ─── Robot scene (imperative, inside Canvas) ───
122
  function RobotScene({
123
+ urdfUrl,
124
+ jointValues,
125
+ onJointsLoaded,
126
+ trailEnabled,
127
+ trailResetKey,
128
+ scale,
129
  }: {
130
  urdfUrl: string;
131
  jointValues: Record<string, number>;
 
140
  const [loading, setLoading] = useState(true);
141
  const [error, setError] = useState<string | null>(null);
142
 
143
+ type TrailState = {
144
+ positions: Float32Array;
145
+ colors: Float32Array;
146
+ times: number[];
147
+ count: number;
148
+ };
149
  const trailsRef = useRef<TrailState[]>([]);
150
  const linesRef = useRef<Line2[]>([]);
151
  const trailMatsRef = useRef<LineMaterial[]>([]);
 
153
 
154
  // Reset trails when episode changes
155
  useEffect(() => {
156
+ for (const t of trailsRef.current) {
157
+ t.count = 0;
158
+ t.times = [];
159
+ }
160
  for (const l of linesRef.current) l.visible = false;
161
  }, [trailResetKey]);
162
 
163
  // Create/destroy trail Line2 objects when tip count changes
164
+ const ensureTrails = useCallback(
165
+ (count: number) => {
166
+ if (trailCountRef.current === count) return;
167
+ // Remove old
168
+ for (const l of linesRef.current) {
169
+ scene.remove(l);
170
+ l.geometry.dispose();
171
+ }
172
+ for (const m of trailMatsRef.current) m.dispose();
173
+ // Create new
174
+ const trails: TrailState[] = [];
175
+ const lines: Line2[] = [];
176
+ const mats: LineMaterial[] = [];
177
+ for (let i = 0; i < count; i++) {
178
+ trails.push({
179
+ positions: new Float32Array(MAX_TRAIL_POINTS * 3),
180
+ colors: new Float32Array(MAX_TRAIL_POINTS * 3),
181
+ times: [],
182
+ count: 0,
183
+ });
184
+ const mat = new LineMaterial({
185
+ color: 0xffffff,
186
+ linewidth: 4,
187
+ vertexColors: true,
188
+ transparent: true,
189
+ worldUnits: false,
190
+ });
191
+ mat.resolution.set(window.innerWidth, window.innerHeight);
192
+ mats.push(mat);
193
+ const line = new Line2(new LineGeometry(), mat);
194
+ line.frustumCulled = false;
195
+ line.visible = false;
196
+ lines.push(line);
197
+ scene.add(line);
198
+ }
199
+ trailsRef.current = trails;
200
+ linesRef.current = lines;
201
+ trailMatsRef.current = mats;
202
+ trailCountRef.current = count;
203
+ },
204
+ [scene],
205
+ );
206
 
207
  useEffect(() => {
208
  setLoading(true);
 
214
  // DAE (Collada) files — load with embedded materials
215
  if (url.endsWith(".dae")) {
216
  const colladaLoader = new ColladaLoader(mgr);
217
+ colladaLoader.load(
218
+ url,
219
+ (collada) => {
220
+ if (isOpenArm) {
221
+ collada.scene.traverse((child) => {
222
+ if (child instanceof THREE.Mesh && child.material) {
223
+ const mat = child.material as THREE.MeshStandardMaterial;
224
+ if (mat.side !== undefined) mat.side = THREE.DoubleSide;
225
+ if (mat.color) {
226
+ const hsl = { h: 0, s: 0, l: 0 };
227
+ mat.color.getHSL(hsl);
228
+ if (hsl.l > 0.7) mat.color.setHSL(hsl.h, hsl.s, 0.55);
229
+ }
230
  }
231
+ });
232
+ }
233
+ onLoad(collada.scene);
234
+ },
235
+ undefined,
236
+ (err) => onLoad(new THREE.Object3D(), err as Error),
237
+ );
238
  return;
239
  }
240
  // STL files — apply custom materials
 
246
  let metalness = 0.1;
247
  let roughness = 0.6;
248
  if (url.includes("sts3215")) {
249
+ color = "#1a1a1a";
250
+ metalness = 0.7;
251
+ roughness = 0.3;
252
  } else if (isOpenArm) {
253
  color = url.includes("body_link0") ? "#3a3a4a" : "#f5f5f5";
254
+ metalness = 0.15;
255
+ roughness = 0.6;
256
  }
257
+ const material = new THREE.MeshStandardMaterial({
258
+ color,
259
+ metalness,
260
+ roughness,
261
+ side: isOpenArm ? THREE.DoubleSide : THREE.FrontSide,
262
+ });
263
  onLoad(new THREE.Mesh(geometry, material));
264
  },
265
  undefined,
 
271
  (robot) => {
272
  robotRef.current = robot;
273
  robot.rotateOnAxis(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
274
+ robot.traverse((c) => {
275
+ c.castShadow = true;
276
+ });
277
  robot.updateMatrixWorld(true);
278
  robot.scale.set(scale, scale, scale);
279
  scene.add(robot);
 
288
  ensureTrails(tips.length);
289
 
290
  const movable = Object.values(robot.joints)
291
+ .filter(
292
+ (j) =>
293
+ j.jointType === "revolute" ||
294
+ j.jointType === "continuous" ||
295
+ j.jointType === "prismatic",
296
+ )
297
  .map((j) => j.name);
298
  onJointsLoaded(movable);
299
  setLoading(false);
300
  },
301
  undefined,
302
+ (err) => {
303
+ console.error("Error loading URDF:", err);
304
+ setError(String(err));
305
+ setLoading(false);
306
+ },
307
  );
308
  return () => {
309
+ if (robotRef.current) {
310
+ scene.remove(robotRef.current);
311
+ robotRef.current = null;
312
+ }
313
  tipLinksRef.current = [];
314
  };
315
  }, [urdfUrl, scale, scene, onJointsLoaded, ensureTrails]);
 
363
  trail.colors[i * 3 + 2] = trailColor.b * t;
364
  }
365
 
366
+ if (trail.count < 2) {
367
+ line.visible = false;
368
+ continue;
369
+ }
370
  const geo = new LineGeometry();
371
+ geo.setPositions(
372
+ Array.from(trail.positions.subarray(0, trail.count * 3)),
373
+ );
374
  geo.setColors(Array.from(trail.colors.subarray(0, trail.count * 3)));
375
  line.geometry.dispose();
376
  line.geometry = geo;
 
379
  }
380
  });
381
 
382
+ if (loading)
383
+ return (
384
+ <Html center>
385
+ <span className="text-white text-lg">Loading robot…</span>
386
+ </Html>
387
+ );
388
+ if (error)
389
+ return (
390
+ <Html center>
391
+ <span className="text-red-400">Failed to load URDF</span>
392
+ </Html>
393
+ );
394
  return null;
395
  }
396
 
397
  // ─── Playback ticker ───
398
  function PlaybackDriver({
399
+ playing,
400
+ fps,
401
+ totalFrames,
402
+ frameRef,
403
+ setFrame,
404
  }: {
405
+ playing: boolean;
406
+ fps: number;
407
+ totalFrames: number;
408
  frameRef: React.MutableRefObject<number>;
409
  setFrame: React.Dispatch<React.SetStateAction<number>>;
410
  }) {
 
450
  }) {
451
  const { datasetInfo, episodes } = data;
452
  const fps = datasetInfo.fps || 30;
453
+ const robotConfig = useMemo(
454
+ () => getRobotConfig(datasetInfo.robot_type),
455
+ [datasetInfo.robot_type],
456
+ );
457
  const { urdfUrl, scale } = robotConfig;
458
 
459
  // Episode selection & chart data
 
464
  [data.episodeId]: data.flatChartData,
465
  });
466
 
467
+ const handleEpisodeChange = useCallback(
468
+ (epId: number) => {
469
+ setSelectedEpisode(epId);
470
+ setFrame(0);
471
+ frameRef.current = 0;
472
+ setPlaying(false);
473
 
474
+ if (chartDataCache.current[epId]) {
475
+ setChartData(chartDataCache.current[epId]);
476
+ return;
477
+ }
478
 
479
+ if (!org || !dataset) return;
480
+ setEpisodeLoading(true);
481
+ fetchEpisodeChartData(org, dataset, epId)
482
+ .then((result) => {
483
+ chartDataCache.current[epId] = result;
484
+ setChartData(result);
485
+ })
486
+ .catch((err) => console.error("Failed to load episode:", err))
487
+ .finally(() => setEpisodeLoading(false));
488
+ },
489
+ [org, dataset],
490
+ );
491
 
492
  const totalFrames = chartData.length;
493
 
494
  // URDF joint names
495
  const [urdfJointNames, setUrdfJointNames] = useState<string[]>([]);
496
+ const onJointsLoaded = useCallback(
497
+ (names: string[]) => setUrdfJointNames(names),
498
+ [],
499
+ );
500
 
501
  // Feature groups
502
  const columnGroups = useMemo(() => {
 
509
  () =>
510
  groupNames.find((g) => g.toLowerCase().includes("state")) ??
511
  groupNames.find((g) => g.toLowerCase().includes("action")) ??
512
+ groupNames[0] ??
513
+ "",
514
  [groupNames],
515
  );
516
 
 
535
  const [playing, setPlaying] = useState(false);
536
  const frameRef = useRef(0);
537
 
538
+ const handleFrameChange = useCallback(
539
+ (e: React.ChangeEvent<HTMLInputElement>) => {
540
+ const f = parseInt(e.target.value);
541
+ setFrame(f);
542
+ frameRef.current = f;
543
+ },
544
+ [],
545
+ );
546
 
547
  // Filter out mimic joints (finger_joint2) from the UI list
548
  const displayJointNames = useMemo(
549
+ () =>
550
+ urdfJointNames.filter((n) => !n.toLowerCase().includes("finger_joint2")),
551
  [urdfJointNames],
552
  );
553
 
 
558
  if (!jn.toLowerCase().includes("finger_joint1")) continue;
559
  const col = mapping[jn];
560
  if (!col) continue;
561
+ let min = Infinity,
562
+ max = -Infinity;
563
  for (const row of chartData) {
564
  const v = row[col];
565
+ if (typeof v === "number") {
566
+ if (v < min) min = v;
567
+ if (v > max) max = v;
568
+ }
569
  }
570
  if (min < max) ranges[jn] = { min, max };
571
  }
 
602
  }
603
 
604
  const converted = detectAndConvert(revoluteValues);
605
+ revoluteNames.forEach((n, i) => {
606
+ values[n] = converted[i];
607
+ });
608
 
609
  // Copy finger_joint1 → finger_joint2 (mimic joints)
610
  for (const jn of urdfJointNames) {
 
620
  const totalTime = (totalFrames / fps).toFixed(2);
621
 
622
  if (data.flatChartData.length === 0) {
623
+ return (
624
+ <div className="text-slate-400 p-8 text-center">
625
+ No trajectory data available.
626
+ </div>
627
+ );
628
  }
629
 
630
  return (
 
633
  <div className="flex-1 min-h-0 bg-slate-950 rounded-lg overflow-hidden border border-slate-700 relative">
634
  {episodeLoading && (
635
  <div className="absolute inset-0 z-10 flex items-center justify-center bg-slate-950/70">
636
+ <span className="text-white text-lg animate-pulse">
637
+ Loading episode {selectedEpisode}…
638
+ </span>
639
  </div>
640
  )}
641
+ <Canvas
642
+ camera={{
643
+ position: [0.3 * scale, 0.25 * scale, 0.3 * scale],
644
+ fov: 45,
645
+ near: 0.01,
646
+ far: 100,
647
+ }}
648
+ >
649
  <ambientLight intensity={0.7} />
650
  <directionalLight position={[3, 5, 4]} intensity={1.5} />
651
  <directionalLight position={[-2, 3, -2]} intensity={0.6} />
652
  <hemisphereLight args={["#b1e1ff", "#666666", 0.5]} />
653
+ <RobotScene
654
+ urdfUrl={urdfUrl}
655
+ jointValues={jointValues}
656
+ onJointsLoaded={onJointsLoaded}
657
+ trailEnabled={trailEnabled}
658
+ trailResetKey={selectedEpisode}
659
+ scale={scale}
660
+ />
661
  <Grid
662
+ args={[10, 10]}
663
+ cellSize={0.2}
664
+ cellThickness={0.5}
665
+ cellColor="#334155"
666
+ sectionSize={1}
667
+ sectionThickness={1}
668
+ sectionColor="#475569"
669
+ fadeDistance={10}
670
+ position={[0, 0, 0]}
671
  />
672
  <OrbitControls target={[0, 0.8, 0]} />
673
+ <PlaybackDriver
674
+ playing={playing}
675
+ fps={fps}
676
+ totalFrames={totalFrames}
677
+ frameRef={frameRef}
678
+ setFrame={setFrame}
679
+ />
680
  </Canvas>
681
  </div>
682
 
 
687
  {/* Episode selector */}
688
  <div className="flex items-center gap-1.5 shrink-0">
689
  <button
690
+ onClick={() => {
691
+ if (selectedEpisode > episodes[0])
692
+ handleEpisodeChange(selectedEpisode - 1);
693
+ }}
694
  disabled={selectedEpisode <= episodes[0]}
695
  className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
696
+ >
697
+
698
+ </button>
699
  <select
700
  value={selectedEpisode}
701
  onChange={(e) => handleEpisodeChange(Number(e.target.value))}
702
  className="bg-slate-900 text-slate-200 text-xs rounded px-1.5 py-1 border border-slate-600 w-28"
703
  >
704
  {episodes.map((ep) => (
705
+ <option key={ep} value={ep}>
706
+ Episode {ep}
707
+ </option>
708
  ))}
709
  </select>
710
  <button
711
+ onClick={() => {
712
+ if (selectedEpisode < episodes[episodes.length - 1])
713
+ handleEpisodeChange(selectedEpisode + 1);
714
+ }}
715
  disabled={selectedEpisode >= episodes[episodes.length - 1]}
716
  className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
717
+ >
718
+
719
+ </button>
720
  </div>
721
 
722
  {/* Play/Pause */}
723
  <button
724
+ onClick={() => {
725
+ setPlaying(!playing);
726
+ if (!playing) frameRef.current = frame;
727
+ }}
728
  className="w-8 h-8 flex items-center justify-center rounded bg-orange-600 hover:bg-orange-500 text-white transition-colors shrink-0"
729
  >
730
  {playing ? (
731
+ <svg width="12" height="14" viewBox="0 0 12 14">
732
+ <rect x="1" y="1" width="3" height="12" fill="white" />
733
+ <rect x="8" y="1" width="3" height="12" fill="white" />
734
+ </svg>
735
  ) : (
736
+ <svg width="12" height="14" viewBox="0 0 12 14">
737
+ <polygon points="2,1 11,7 2,13" fill="white" />
738
+ </svg>
739
  )}
740
  </button>
741
 
 
743
  <button
744
  onClick={() => setTrailEnabled((v) => !v)}
745
  className={`px-2 h-8 text-xs rounded transition-colors shrink-0 ${
746
+ trailEnabled
747
+ ? "bg-orange-600/30 text-orange-400 border border-orange-500"
748
+ : "bg-slate-700 text-slate-400 border border-slate-600"
749
  }`}
750
  title={trailEnabled ? "Hide trail" : "Show trail"}
751
+ >
752
+ Trail
753
+ </button>
754
 
755
  {/* Scrubber */}
756
+ <input
757
+ type="range"
758
+ min={0}
759
+ max={Math.max(totalFrames - 1, 0)}
760
+ value={frame}
761
+ onChange={handleFrameChange}
762
+ className="flex-1 h-1.5 accent-orange-500 cursor-pointer"
763
+ />
764
+ <span className="text-xs text-slate-400 tabular-nums w-28 text-right shrink-0">
765
+ {currentTime}s / {totalTime}s
766
+ </span>
767
+ <span className="text-xs text-slate-500 tabular-nums w-20 text-right shrink-0">
768
+ F {frame}/{Math.max(totalFrames - 1, 0)}
769
+ </span>
770
  </div>
771
 
772
  {/* Collapsible joint mapping */}
 
774
  onClick={() => setShowMapping((v) => !v)}
775
  className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
776
  >
777
+ <span
778
+ className={`transition-transform ${showMapping ? "rotate-90" : ""}`}
779
+ >
780
+
781
+ </span>
782
  Joint Mapping
783
+ <span className="text-slate-600">
784
+ ({Object.keys(mapping).filter((k) => mapping[k]).length}/
785
+ {displayJointNames.length} mapped)
786
+ </span>
787
  </button>
788
 
789
  {showMapping && (
 
792
  <label className="text-xs text-slate-400">Data source</label>
793
  <div className="flex gap-1 flex-wrap">
794
  {groupNames.map((name) => (
795
+ <button
796
+ key={name}
797
+ onClick={() => setSelectedGroup(name)}
798
  className={`px-2 py-1 text-xs rounded transition-colors ${
799
+ selectedGroup === name
800
+ ? "bg-orange-600 text-white"
801
+ : "bg-slate-700 text-slate-300 hover:bg-slate-600"
802
+ }`}
803
+ >
804
+ {name}
805
+ </button>
806
  ))}
807
  </div>
808
  </div>
 
813
  <tr className="text-slate-500">
814
  <th className="text-left font-normal px-1">URDF Joint</th>
815
  <th className="text-left font-normal px-1">→</th>
816
+ <th className="text-left font-normal px-1">
817
+ Dataset Column
818
+ </th>
819
  <th className="text-right font-normal px-1">Value</th>
820
  </tr>
821
  </thead>
822
  <tbody>
823
  {displayJointNames.map((jointName) => (
824
+ <tr
825
+ key={jointName}
826
+ className="border-t border-slate-700/50"
827
+ >
828
+ <td className="px-1 py-0.5 text-slate-300 font-mono">
829
+ {jointName}
830
+ </td>
831
  <td className="px-1 text-slate-600">→</td>
832
  <td className="px-1 py-0.5">
833
+ <select
834
+ value={mapping[jointName] ?? ""}
835
+ onChange={(e) =>
836
+ setMapping((m) => ({
837
+ ...m,
838
+ [jointName]: e.target.value,
839
+ }))
840
+ }
841
+ className="bg-slate-900 text-slate-200 text-xs rounded px-1 py-0.5 border border-slate-600 w-full max-w-[200px]"
842
+ >
843
  <option value="">-- unmapped --</option>
844
  {selectedColumns.map((col) => {
845
  const label = col.split(SERIES_DELIM).pop() ?? col;
846
+ return (
847
+ <option key={col} value={col}>
848
+ {label}
849
+ </option>
850
+ );
851
  })}
852
  </select>
853
  </td>
854
  <td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
855
+ {jointValues[jointName] !== undefined
856
+ ? jointValues[jointName].toFixed(3)
857
+ : "—"}
858
  </td>
859
  </tr>
860
  ))}
src/components/videos-player.tsx CHANGED
@@ -149,7 +149,8 @@ export const VideosPlayer = ({
149
  // For the primary video, only seek when the change came from an external source
150
  // (slider drag, chart click, etc.) — detected by comparing against lastVideoTimeRef.
151
  useEffect(() => {
152
- const isExternalSeek = Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
 
153
 
154
  videoRefs.current.forEach((video, index) => {
155
  if (!video) return;
@@ -176,7 +177,7 @@ export const VideosPlayer = ({
176
  const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
177
  const video = e.target as HTMLVideoElement;
178
  if (video && video.duration) {
179
- const videoIndex = videoRefs.current.findIndex(ref => ref === video);
180
  const videoInfo = videosInfo[videoIndex];
181
 
182
  if (videoInfo?.isSegmented) {
@@ -220,11 +221,11 @@ export const VideosPlayer = ({
220
  }
221
  }
222
  };
223
-
224
- video.addEventListener('timeupdate', handleTimeUpdate);
225
-
226
  videoCleanupHandlers.set(video, () => {
227
- video.removeEventListener('timeupdate', handleTimeUpdate);
228
  });
229
  }
230
 
 
149
  // For the primary video, only seek when the change came from an external source
150
  // (slider drag, chart click, etc.) — detected by comparing against lastVideoTimeRef.
151
  useEffect(() => {
152
+ const isExternalSeek =
153
+ Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
154
 
155
  videoRefs.current.forEach((video, index) => {
156
  if (!video) return;
 
177
  const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
178
  const video = e.target as HTMLVideoElement;
179
  if (video && video.duration) {
180
+ const videoIndex = videoRefs.current.findIndex((ref) => ref === video);
181
  const videoInfo = videosInfo[videoIndex];
182
 
183
  if (videoInfo?.isSegmented) {
 
221
  }
222
  }
223
  };
224
+
225
+ video.addEventListener("timeupdate", handleTimeUpdate);
226
+
227
  videoCleanupHandlers.set(video, () => {
228
+ video.removeEventListener("timeupdate", handleTimeUpdate);
229
  });
230
  }
231
 
src/context/flagged-episodes-context.tsx CHANGED
@@ -1,6 +1,13 @@
1
  "use client";
2
 
3
- import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from "react";
 
 
 
 
 
 
 
4
 
5
  const STORAGE_KEY = "flagged-episodes";
6
 
@@ -9,12 +16,18 @@ function loadFromStorage(): Set<number> {
9
  try {
10
  const raw = sessionStorage.getItem(STORAGE_KEY);
11
  if (raw) return new Set(JSON.parse(raw) as number[]);
12
- } catch { /* ignore */ }
 
 
13
  return new Set();
14
  }
15
 
16
  function saveToStorage(s: Set<number>) {
17
- try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...s])); } catch { /* ignore */ }
 
 
 
 
18
  }
19
 
20
  type FlaggedEpisodesContextType = {
@@ -26,29 +39,39 @@ type FlaggedEpisodesContextType = {
26
  clear: () => void;
27
  };
28
 
29
- const FlaggedEpisodesContext = createContext<FlaggedEpisodesContextType | undefined>(undefined);
 
 
30
 
31
  export function useFlaggedEpisodes() {
32
  const ctx = useContext(FlaggedEpisodesContext);
33
- if (!ctx) throw new Error("useFlaggedEpisodes must be used within FlaggedEpisodesProvider");
 
 
 
34
  return ctx;
35
  }
36
 
37
- export const FlaggedEpisodesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
 
 
38
  const [flagged, setFlagged] = useState<Set<number>>(() => loadFromStorage());
39
 
40
- useEffect(() => { saveToStorage(flagged); }, [flagged]);
 
 
41
 
42
  const toggle = useCallback((id: number) => {
43
- setFlagged(prev => {
44
  const next = new Set(prev);
45
- if (next.has(id)) next.delete(id); else next.add(id);
 
46
  return next;
47
  });
48
  }, []);
49
 
50
  const addMany = useCallback((ids: number[]) => {
51
- setFlagged(prev => {
52
  const next = new Set(prev);
53
  for (const id of ids) next.add(id);
54
  return next;
@@ -59,9 +82,17 @@ export const FlaggedEpisodesProvider: React.FC<{ children: React.ReactNode }> =
59
 
60
  const has = useCallback((id: number) => flagged.has(id), [flagged]);
61
 
62
- const value = useMemo(() => ({
63
- flagged, count: flagged.size, has, toggle, addMany, clear,
64
- }), [flagged, has, toggle, addMany, clear]);
 
 
 
 
 
 
 
 
65
 
66
  return (
67
  <FlaggedEpisodesContext.Provider value={value}>
 
1
  "use client";
2
 
3
+ import React, {
4
+ createContext,
5
+ useContext,
6
+ useState,
7
+ useCallback,
8
+ useMemo,
9
+ useEffect,
10
+ } from "react";
11
 
12
  const STORAGE_KEY = "flagged-episodes";
13
 
 
16
  try {
17
  const raw = sessionStorage.getItem(STORAGE_KEY);
18
  if (raw) return new Set(JSON.parse(raw) as number[]);
19
+ } catch {
20
+ /* ignore */
21
+ }
22
  return new Set();
23
  }
24
 
25
  function saveToStorage(s: Set<number>) {
26
+ try {
27
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...s]));
28
+ } catch {
29
+ /* ignore */
30
+ }
31
  }
32
 
33
  type FlaggedEpisodesContextType = {
 
39
  clear: () => void;
40
  };
41
 
42
+ const FlaggedEpisodesContext = createContext<
43
+ FlaggedEpisodesContextType | undefined
44
+ >(undefined);
45
 
46
  export function useFlaggedEpisodes() {
47
  const ctx = useContext(FlaggedEpisodesContext);
48
+ if (!ctx)
49
+ throw new Error(
50
+ "useFlaggedEpisodes must be used within FlaggedEpisodesProvider",
51
+ );
52
  return ctx;
53
  }
54
 
55
+ export const FlaggedEpisodesProvider: React.FC<{
56
+ children: React.ReactNode;
57
+ }> = ({ children }) => {
58
  const [flagged, setFlagged] = useState<Set<number>>(() => loadFromStorage());
59
 
60
+ useEffect(() => {
61
+ saveToStorage(flagged);
62
+ }, [flagged]);
63
 
64
  const toggle = useCallback((id: number) => {
65
+ setFlagged((prev) => {
66
  const next = new Set(prev);
67
+ if (next.has(id)) next.delete(id);
68
+ else next.add(id);
69
  return next;
70
  });
71
  }, []);
72
 
73
  const addMany = useCallback((ids: number[]) => {
74
+ setFlagged((prev) => {
75
  const next = new Set(prev);
76
  for (const id of ids) next.add(id);
77
  return next;
 
82
 
83
  const has = useCallback((id: number) => flagged.has(id), [flagged]);
84
 
85
+ const value = useMemo(
86
+ () => ({
87
+ flagged,
88
+ count: flagged.size,
89
+ has,
90
+ toggle,
91
+ addMany,
92
+ clear,
93
+ }),
94
+ [flagged, has, toggle, addMany, clear],
95
+ );
96
 
97
  return (
98
  <FlaggedEpisodesContext.Provider value={value}>
src/lib/so101-robot.ts CHANGED
@@ -1,7 +1,11 @@
1
  export function isSO101Robot(robotType: string | null): boolean {
2
  if (!robotType) return false;
3
  const lower = robotType.toLowerCase();
4
- return lower.includes("so100") || lower.includes("so101") || lower === "so_follower";
 
 
 
 
5
  }
6
 
7
  export function isOpenArmRobot(robotType: string | null): boolean {
 
1
  export function isSO101Robot(robotType: string | null): boolean {
2
  if (!robotType) return false;
3
  const lower = robotType.toLowerCase();
4
+ return (
5
+ lower.includes("so100") ||
6
+ lower.includes("so101") ||
7
+ lower === "so_follower"
8
+ );
9
  }
10
 
11
  export function isOpenArmRobot(robotType: string | null): boolean {
src/utils/versionUtils.ts CHANGED
@@ -32,7 +32,10 @@ export interface DatasetInfo {
32
  }
33
 
34
  // In-memory cache for dataset info (5 min TTL)
35
- const datasetInfoCache = new Map<string, { data: DatasetInfo; expiry: number }>();
 
 
 
36
  const CACHE_TTL_MS = 5 * 60 * 1000;
37
 
38
  export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
@@ -48,8 +51,8 @@ export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
48
 
49
  const controller = new AbortController();
50
  const timeoutId = setTimeout(() => controller.abort(), 10000);
51
-
52
- const response = await fetch(testUrl, {
53
  method: "GET",
54
  cache: "no-store",
55
  signal: controller.signal,
@@ -62,14 +65,17 @@ export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
62
  }
63
 
64
  const data = await response.json();
65
-
66
  if (!data.features) {
67
  throw new Error(
68
  "Dataset info.json does not have the expected features structure",
69
  );
70
  }
71
-
72
- datasetInfoCache.set(repoId, { data: data as DatasetInfo, expiry: Date.now() + CACHE_TTL_MS });
 
 
 
73
  return data as DatasetInfo;
74
  } catch (error) {
75
  if (error instanceof Error) {
@@ -88,7 +94,9 @@ const SUPPORTED_VERSIONS = ["v3.0", "v2.1", "v2.0"];
88
  * Returns both the validated version string and the dataset info in one call,
89
  * avoiding a duplicate info.json fetch.
90
  */
91
- export async function getDatasetVersionAndInfo(repoId: string): Promise<{ version: string; info: DatasetInfo }> {
 
 
92
  const info = await getDatasetInfo(repoId);
93
  const version = info.codebase_version;
94
  if (!version) {
@@ -97,8 +105,8 @@ export async function getDatasetVersionAndInfo(repoId: string): Promise<{ versio
97
  if (!SUPPORTED_VERSIONS.includes(version)) {
98
  throw new Error(
99
  `Dataset ${repoId} has codebase version ${version}, which is not supported. ` +
100
- "This tool only works with dataset versions 3.0, 2.1, or 2.0. " +
101
- "Please use a compatible dataset version."
102
  );
103
  }
104
  return { version, info };
 
32
  }
33
 
34
  // In-memory cache for dataset info (5 min TTL)
35
+ const datasetInfoCache = new Map<
36
+ string,
37
+ { data: DatasetInfo; expiry: number }
38
+ >();
39
  const CACHE_TTL_MS = 5 * 60 * 1000;
40
 
41
  export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
 
51
 
52
  const controller = new AbortController();
53
  const timeoutId = setTimeout(() => controller.abort(), 10000);
54
+
55
+ const response = await fetch(testUrl, {
56
  method: "GET",
57
  cache: "no-store",
58
  signal: controller.signal,
 
65
  }
66
 
67
  const data = await response.json();
68
+
69
  if (!data.features) {
70
  throw new Error(
71
  "Dataset info.json does not have the expected features structure",
72
  );
73
  }
74
+
75
+ datasetInfoCache.set(repoId, {
76
+ data: data as DatasetInfo,
77
+ expiry: Date.now() + CACHE_TTL_MS,
78
+ });
79
  return data as DatasetInfo;
80
  } catch (error) {
81
  if (error instanceof Error) {
 
94
  * Returns both the validated version string and the dataset info in one call,
95
  * avoiding a duplicate info.json fetch.
96
  */
97
+ export async function getDatasetVersionAndInfo(
98
+ repoId: string,
99
+ ): Promise<{ version: string; info: DatasetInfo }> {
100
  const info = await getDatasetInfo(repoId);
101
  const version = info.codebase_version;
102
  if (!version) {
 
105
  if (!SUPPORTED_VERSIONS.includes(version)) {
106
  throw new Error(
107
  `Dataset ${repoId} has codebase version ${version}, which is not supported. ` +
108
+ "This tool only works with dataset versions 3.0, 2.1, or 2.0. " +
109
+ "Please use a compatible dataset version.",
110
  );
111
  }
112
  return { version, info };