pepijn223 HF Staff commited on
Commit
9f9a669
·
unverified ·
1 Parent(s): 824eff6

increase text size, remove unneeded stats

Browse files
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx CHANGED
@@ -7,6 +7,7 @@ import { SimpleVideosPlayer } from "@/components/simple-videos-player";
7
  import DataRecharts from "@/components/data-recharts";
8
  import PlaybackBar from "@/components/playback-bar";
9
  import { TimeProvider, useTime } from "@/context/time-context";
 
10
  import Sidebar from "@/components/side-nav";
11
  import StatsPanel from "@/components/stats-panel";
12
  import OverviewPanel from "@/components/overview-panel";
@@ -25,8 +26,9 @@ import { fetchEpisodeLengthStats, fetchEpisodeFrames, fetchCrossEpisodeVariance
25
 
26
  const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
27
  const ActionInsightsPanel = lazy(() => import("@/components/action-insights-panel"));
 
28
 
29
- type ActiveTab = "episodes" | "statistics" | "frames" | "insights" | "urdf";
30
 
31
  export default function EpisodeViewer({
32
  data,
@@ -51,7 +53,9 @@ export default function EpisodeViewer({
51
  }
52
  return (
53
  <TimeProvider duration={data!.duration}>
54
- <EpisodeViewerInner data={data!} org={org} dataset={dataset} />
 
 
55
  </TimeProvider>
56
  );
57
  }
@@ -81,7 +85,15 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
81
  const searchParams = useSearchParams();
82
 
83
  // Tab state & lazy stats
84
- const [activeTab, setActiveTab] = useState<ActiveTab>("episodes");
 
 
 
 
 
 
 
 
85
  const [columnMinMax, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
86
  const [episodeLengthStats, setEpisodeLengthStats] = useState<EpisodeLengthStats | null>(null);
87
  const [statsLoading, setStatsLoading] = useState(false);
@@ -89,10 +101,23 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
89
  const [episodeFramesData, setEpisodeFramesData] = useState<EpisodeFramesData | null>(null);
90
  const [framesLoading, setFramesLoading] = useState(false);
91
  const framesLoadedRef = useRef(false);
 
 
 
 
 
 
 
 
92
  const [crossEpData, setCrossEpData] = useState<CrossEpisodeVarianceData | null>(null);
93
  const [insightsLoading, setInsightsLoading] = useState(false);
94
  const insightsLoadedRef = useRef(false);
95
 
 
 
 
 
 
96
  const loadStats = () => {
97
  if (statsLoadedRef.current) return;
98
  statsLoadedRef.current = true;
@@ -128,11 +153,21 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
128
  .finally(() => setInsightsLoading(false));
129
  };
130
 
 
 
 
 
 
 
 
 
 
131
  const handleTabChange = (tab: ActiveTab) => {
132
  setActiveTab(tab);
133
  if (tab === "statistics") loadStats();
134
  if (tab === "frames") loadFrames();
135
  if (tab === "insights") loadInsights();
 
136
  };
137
 
138
  // Use context for time sync
@@ -291,6 +326,19 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
291
  <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
292
  )}
293
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  <button
295
  className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
296
  activeTab === "frames"
@@ -346,6 +394,8 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
346
  currentPage={currentPage}
347
  prevPage={prevPage}
348
  nextPage={nextPage}
 
 
349
  />
350
  )}
351
 
@@ -431,7 +481,7 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
431
  )}
432
 
433
  {activeTab === "frames" && (
434
- <OverviewPanel data={episodeFramesData} loading={framesLoading} />
435
  )}
436
 
437
  {activeTab === "insights" && (
@@ -445,6 +495,23 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
445
  </Suspense>
446
  )}
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  {activeTab === "urdf" && (
449
  <Suspense fallback={<Loading />}>
450
  <URDFViewer data={data} org={org} dataset={dataset} />
 
7
  import DataRecharts from "@/components/data-recharts";
8
  import PlaybackBar from "@/components/playback-bar";
9
  import { TimeProvider, useTime } from "@/context/time-context";
10
+ import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context";
11
  import Sidebar from "@/components/side-nav";
12
  import StatsPanel from "@/components/stats-panel";
13
  import OverviewPanel from "@/components/overview-panel";
 
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,
 
53
  }
54
  return (
55
  <TimeProvider duration={data!.duration}>
56
+ <FlaggedEpisodesProvider>
57
+ <EpisodeViewerInner data={data!} org={org} dataset={dataset} />
58
+ </FlaggedEpisodesProvider>
59
  </TimeProvider>
60
  );
61
  }
 
85
  const searchParams = useSearchParams();
86
 
87
  // Tab state & lazy stats
88
+ const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
89
+ if (typeof window !== "undefined") {
90
+ const stored = sessionStorage.getItem("activeTab");
91
+ if (stored && ["episodes", "statistics", "frames", "insights", "filtering", "urdf"].includes(stored)) {
92
+ return stored as ActiveTab;
93
+ }
94
+ }
95
+ return "episodes";
96
+ });
97
  const [columnMinMax, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
98
  const [episodeLengthStats, setEpisodeLengthStats] = useState<EpisodeLengthStats | null>(null);
99
  const [statsLoading, setStatsLoading] = useState(false);
 
101
  const [episodeFramesData, setEpisodeFramesData] = useState<EpisodeFramesData | null>(null);
102
  const [framesLoading, setFramesLoading] = useState(false);
103
  const framesLoadedRef = useRef(false);
104
+ const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(() => {
105
+ if (typeof window !== "undefined") return sessionStorage.getItem("framesFlaggedOnly") === "true";
106
+ return false;
107
+ });
108
+ const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(() => {
109
+ if (typeof window !== "undefined") return sessionStorage.getItem("sidebarFlaggedOnly") === "true";
110
+ return false;
111
+ });
112
  const [crossEpData, setCrossEpData] = useState<CrossEpisodeVarianceData | null>(null);
113
  const [insightsLoading, setInsightsLoading] = useState(false);
114
  const insightsLoadedRef = useRef(false);
115
 
116
+ // Persist UI state across episode navigations
117
+ useEffect(() => { sessionStorage.setItem("activeTab", activeTab); }, [activeTab]);
118
+ useEffect(() => { sessionStorage.setItem("sidebarFlaggedOnly", String(sidebarFlaggedOnly)); }, [sidebarFlaggedOnly]);
119
+ useEffect(() => { sessionStorage.setItem("framesFlaggedOnly", String(framesFlaggedOnly)); }, [framesFlaggedOnly]);
120
+
121
  const loadStats = () => {
122
  if (statsLoadedRef.current) return;
123
  statsLoadedRef.current = true;
 
153
  .finally(() => setInsightsLoading(false));
154
  };
155
 
156
+ // Re-trigger data loading for the restored tab on mount
157
+ useEffect(() => {
158
+ if (activeTab === "statistics") loadStats();
159
+ if (activeTab === "frames") loadFrames();
160
+ if (activeTab === "insights") loadInsights();
161
+ if (activeTab === "filtering") { loadStats(); loadInsights(); }
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ }, []);
164
+
165
  const handleTabChange = (tab: ActiveTab) => {
166
  setActiveTab(tab);
167
  if (tab === "statistics") loadStats();
168
  if (tab === "frames") loadFrames();
169
  if (tab === "insights") loadInsights();
170
+ if (tab === "filtering") { loadStats(); loadInsights(); }
171
  };
172
 
173
  // Use context for time sync
 
326
  <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
327
  )}
328
  </button>
329
+ <button
330
+ className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
331
+ activeTab === "filtering"
332
+ ? "text-orange-400"
333
+ : "text-slate-400 hover:text-slate-200"
334
+ }`}
335
+ onClick={() => handleTabChange("filtering")}
336
+ >
337
+ Filtering
338
+ {activeTab === "filtering" && (
339
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
340
+ )}
341
+ </button>
342
  <button
343
  className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
344
  activeTab === "frames"
 
394
  currentPage={currentPage}
395
  prevPage={prevPage}
396
  nextPage={nextPage}
397
+ showFlaggedOnly={sidebarFlaggedOnly}
398
+ onShowFlaggedOnlyChange={setSidebarFlaggedOnly}
399
  />
400
  )}
401
 
 
481
  )}
482
 
483
  {activeTab === "frames" && (
484
+ <OverviewPanel data={episodeFramesData} loading={framesLoading} flaggedOnly={framesFlaggedOnly} onFlaggedOnlyChange={setFramesFlaggedOnly} />
485
  )}
486
 
487
  {activeTab === "insights" && (
 
495
  </Suspense>
496
  )}
497
 
498
+ {activeTab === "filtering" && (
499
+ <Suspense fallback={<Loading />}>
500
+ <FilteringPanel
501
+ repoId={datasetInfo.repoId}
502
+ crossEpisodeData={crossEpData}
503
+ crossEpisodeLoading={insightsLoading}
504
+ episodeLengthStats={episodeLengthStats}
505
+ flatChartData={data.flatChartData}
506
+ fps={datasetInfo.fps || 30}
507
+ onViewFlaggedEpisodes={() => {
508
+ setSidebarFlaggedOnly(true);
509
+ handleTabChange("episodes");
510
+ }}
511
+ />
512
+ </Suspense>
513
+ )}
514
+
515
  {activeTab === "urdf" && (
516
  <Suspense fallback={<Loading />}>
517
  <URDFViewer data={data} org={org} dataset={dataset} />
src/app/globals.css CHANGED
@@ -19,6 +19,11 @@
19
  }
20
  }
21
 
 
 
 
 
 
22
  body {
23
  background: var(--background);
24
  color: var(--foreground);
 
19
  }
20
  }
21
 
22
+ html {
23
+ /* Scale all rem-based sizes (text, padding, buttons) up ~12% */
24
+ font-size: 18px;
25
+ }
26
+
27
  body {
28
  background: var(--background);
29
  color: var(--foreground);
src/components/action-insights-panel.tsx CHANGED
@@ -1,6 +1,7 @@
1
  "use client";
2
 
3
- import React, { useMemo, useState } from "react";
 
4
  import {
5
  LineChart,
6
  Line,
@@ -10,9 +11,98 @@ import {
10
  ResponsiveContainer,
11
  Tooltip,
12
  } from "recharts";
13
- import type { CrossEpisodeVarianceData, LowMovementEpisode, AggVelocityStat, AggAutocorrelation, SpeedDistEntry, JerkyEpisode, TrajectoryClustering, AggAlignment } from "@/app/[org]/[dataset]/[episode]/fetch-data";
 
14
 
15
  const DELIMITER = " | ";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  const COLORS = [
17
  "#f97316", "#3b82f6", "#22c55e", "#ef4444", "#a855f7",
18
  "#eab308", "#06b6d4", "#ec4899", "#14b8a6", "#f59e0b",
@@ -60,6 +150,7 @@ function findDecorrelationLag(acf: number[], threshold = 0.5): number | null {
60
  }
61
 
62
  function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<string, number>[]; fps: number; agg?: AggAutocorrelation | null; numEpisodes?: number }) {
 
63
  const actionKeys = useMemo(() => (data.length > 0 ? getActionKeys(data[0]) : []), [data]);
64
  const maxLag = useMemo(() => Math.min(Math.floor(data.length / 2), 100), [data]);
65
 
@@ -85,15 +176,29 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
85
  }, [data, actionKeys, maxLag, fps, agg]);
86
 
87
  const { chartData, suggestedChunk, shortKeys } = agg ?? fallback ?? { chartData: [], suggestedChunk: null, shortKeys: [] };
88
- const numEpisodesLabel = agg ? ` (${numEpisodes} episodes sampled)` : " (current episode)";
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  if (shortKeys.length === 0) return <p className="text-slate-500 italic">No action columns found.</p>;
91
 
92
  return (
93
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
94
  <div>
 
95
  <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>
96
- <p className="text-xs text-slate-400 mt-1">
 
97
  Shows how correlated each action dimension is with itself over increasing time lags.
98
  Where autocorrelation drops below 0.5 suggests a <span className="text-orange-400 font-medium">natural action chunk boundary</span> — actions
99
  beyond this lag are essentially independent, so executing them open-loop offers diminishing returns.
@@ -103,6 +208,8 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
103
  (Zhang et al., 2025 — arXiv:2507.09061, Theorem 1).
104
  </span>
105
  </p>
 
 
106
  </div>
107
 
108
  {suggestedChunk && (
@@ -117,16 +224,16 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
117
  </div>
118
  )}
119
 
120
- <div className="h-64">
121
  <ResponsiveContainer width="100%" height="100%">
122
- <LineChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 16 }}>
123
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
124
  <XAxis
125
  dataKey="lag"
126
  stroke="#94a3b8"
127
- label={{ value: "Lag (steps)", position: "insideBottom", offset: -8, fill: "#94a3b8", fontSize: 11 }}
128
  />
129
- <YAxis stroke="#94a3b8" domain={[-0.2, 1]} />
130
  <Tooltip
131
  contentStyle={{ background: "#1e293b", border: "1px solid #475569", borderRadius: 6 }}
132
  labelFormatter={(v) => `Lag ${v} (${(Number(v) / fps).toFixed(2)}s)`}
@@ -161,7 +268,7 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
161
  {shortKeys.map((name, i) => (
162
  <div key={name} className="flex items-center gap-1.5">
163
  <span className="w-3 h-[3px] rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
164
- <span className="text-[11px] text-slate-400">{name}</span>
165
  </div>
166
  ))}
167
  </div>
@@ -244,8 +351,10 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
244
  return (
245
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
246
  <div>
 
247
  <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>
248
- <p className="text-xs text-slate-400 mt-1">
 
249
  Shows the distribution of frame-to-frame action changes (Δa = a<sub>t+1</sub> − a<sub>t</sub>) for each dimension.
250
  A <span className="text-green-400">tight distribution around zero</span> means smooth, predictable control — the system
251
  is likely stable and benefits from longer action chunks.
@@ -257,6 +366,8 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
257
  compounding error bounds (Assumptions 3.1, 4.1).
258
  </span>
259
  </p>
 
 
260
  </div>
261
 
262
  {/* Per-dimension mini histograms + stats */}
@@ -265,8 +376,8 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
265
  const barH = 28;
266
  return (
267
  <div key={s.name} className="bg-slate-900/50 rounded-md px-2.5 py-2 space-y-1">
268
- <p className="text-[11px] font-medium text-slate-200 truncate" title={s.name}>{s.name}</p>
269
- <div className="flex gap-2 text-[9px] text-slate-400 tabular-nums">
270
  <span>σ={s.std.toFixed(4)}</span>
271
  <span>|Δ|<sub>max</sub>={s.maxAbs.toFixed(4)}</span>
272
  </div>
@@ -315,16 +426,20 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
315
  <p className="text-sm font-medium text-slate-200">
316
  Most Jerky Episodes <span className="text-xs text-slate-500 font-normal">sorted by mean |Δa|</span>
317
  </p>
318
- {episodes.length > 15 && (
319
- <button onClick={() => setShowAll(v => !v)} className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
320
- {showAll ? "Show top 15" : `Show all ${episodes.length}`}
321
- </button>
322
- )}
 
 
 
323
  </div>
324
  <div className="max-h-48 overflow-y-auto">
325
  <table className="w-full text-xs">
326
  <thead>
327
  <tr className="text-slate-500 border-b border-slate-700">
 
328
  <th className="text-left py-1 pr-3">Episode</th>
329
  <th className="text-right py-1">Mean |Δa|</th>
330
  </tr>
@@ -332,6 +447,7 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
332
  <tbody>
333
  {display.map(e => (
334
  <tr key={e.episodeIndex} className="border-b border-slate-800/40 text-slate-300">
 
335
  <td className="py-1 pr-3">ep {e.episodeIndex}</td>
336
  <td className="py-1 text-right tabular-nums">{e.meanAbsDelta.toFixed(4)}</td>
337
  </tr>
@@ -370,19 +486,20 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
370
  );
371
  }
372
 
 
373
  const { actionNames, timeBins, variance, numEpisodes } = data;
374
  const numDims = actionNames.length;
375
  const numBins = timeBins.length;
376
 
377
- // Find global max variance for color scale
378
  const maxVar = Math.max(...variance.flat(), 1e-10);
379
 
380
- // Heatmap dimensions
381
- const cellW = Math.max(6, Math.min(14, Math.floor(560 / numBins)));
382
- const cellH = Math.max(20, Math.min(36, Math.floor(300 / numDims)));
 
383
  const labelW = 100;
384
- const svgW = labelW + numBins * cellW + 60; // 60 for color bar
385
- const svgH = numDims * cellH + 40; // 40 for x-axis label
386
 
387
  function varColor(v: number): string {
388
  const t = Math.sqrt(v / maxVar); // sqrt for better visual spread
@@ -396,11 +513,13 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
396
  return (
397
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
398
  <div>
 
399
  <h3 className="text-sm font-semibold text-slate-200">
400
  Cross-Episode Action Variance
401
  <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes sampled)</span>
402
  </h3>
403
- <p className="text-xs text-slate-400 mt-1">
 
404
  Shows how much each action dimension varies across episodes at each point in time (normalized 0–100%).
405
  <span className="text-orange-400"> High-variance regions</span> indicate multi-modal or inconsistent demonstrations —
406
  generative policies (diffusion, flow-matching) and action chunking help here by modeling multiple modes.
@@ -411,6 +530,8 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
411
  exploratory coverage needed to prevent compounding errors (Section 4).
412
  </span>
413
  </p>
 
 
414
  </div>
415
 
416
  <div className="overflow-x-auto">
@@ -514,48 +635,10 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
514
  );
515
  }
516
 
517
- // ─── Low-Movement Episodes ──────────────────────────────────────
518
-
519
- function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
520
- if (episodes.length === 0) return null;
521
-
522
- const maxMovement = Math.max(...episodes.map(e => e.totalMovement), 1e-10);
523
-
524
- return (
525
- <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-3">
526
- <div>
527
- <h3 className="text-sm font-semibold text-slate-200">Lowest-Movement Episodes</h3>
528
- <p className="text-xs text-slate-400 mt-1">
529
- Episodes with the lowest average action change per frame (mean ‖Δa<sub>t</sub>‖). Very low values may indicate the robot
530
- was <span className="text-yellow-400">standing still</span> or the episode was recorded incorrectly.
531
- </p>
532
- </div>
533
- <div className="grid gap-2" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}>
534
- {episodes.map(ep => (
535
- <div key={ep.episodeIndex} className="bg-slate-900/50 rounded-md px-3 py-2 flex items-center gap-3">
536
- <span className="text-xs text-slate-300 font-medium shrink-0">ep {ep.episodeIndex}</span>
537
- <div className="flex-1 min-w-0">
538
- <div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
539
- <div
540
- className="h-full rounded-full"
541
- style={{
542
- width: `${Math.max(2, (ep.totalMovement / maxMovement) * 100)}%`,
543
- background: ep.totalMovement / maxMovement < 0.15 ? "#ef4444" : ep.totalMovement / maxMovement < 0.4 ? "#eab308" : "#22c55e",
544
- }}
545
- />
546
- </div>
547
- </div>
548
- <span className="text-[10px] text-slate-500 tabular-nums shrink-0">{ep.totalMovement.toFixed(2)}</span>
549
- </div>
550
- ))}
551
- </div>
552
- </div>
553
- );
554
- }
555
-
556
  // ─── Demonstrator Speed Variance ────────────────────────────────
557
 
558
  function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: SpeedDistEntry[]; numEpisodes: number }) {
 
559
  const { speeds, mean, std, cv, median, bins, lo, binW, maxBin, verdict } = useMemo(() => {
560
  const sp = distribution.map(d => d.speed).sort((a, b) => a - b);
561
  const m = sp.reduce((a, b) => a + b, 0) / sp.length;
@@ -579,27 +662,31 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
579
 
580
  if (speeds.length < 3) return null;
581
 
582
- const barH = 100;
583
- const barW = Math.max(8, Math.floor(500 / bins.length));
584
 
585
  return (
586
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
587
  <div>
588
- <h3 className="text-sm font-semibold text-slate-200">
589
- Demonstrator Speed Variance
590
- <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes)</span>
591
- </h3>
592
- <p className="text-xs text-slate-400 mt-1">
593
- Distribution of average execution speed (mean ‖Δa<sub>t</sub>‖ per frame) across all episodes.
594
- Different human demonstrators often execute at <span className="text-orange-400">different speeds</span>, creating
595
- artificial multimodality in the action distribution that confuses the policy. A coefficient of variation (CV) above 0.3
596
- strongly suggests normalizing trajectory speed before training.
597
- <br />
598
- <span className="text-slate-500">
599
- Based on &quot;Is Diversity All You Need&quot; (AGI-Bot, 2025) which shows velocity normalization dramatically improves
600
- fine-tuning success rate. Also relates to ACT (Zhao et al., 2023) and Pi0.5 (Physical Intelligence, 2025).
601
- </span>
602
- </p>
 
 
 
 
603
  </div>
604
 
605
  <div className="flex gap-4">
@@ -651,6 +738,7 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
651
  // ─── State–Action Temporal Alignment ────────────────────────────
652
 
653
  function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Record<string, number>[]; fps: number; agg?: AggAlignment | null; numEpisodes?: number }) {
 
654
  const result = useMemo(() => {
655
  if (agg) return { ...agg, fromAgg: true };
656
  if (data.length < 10) return null;
@@ -739,23 +827,27 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
739
  return (
740
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
741
  <div>
742
- <h3 className="text-sm font-semibold text-slate-200">
743
- State–Action Temporal Alignment
744
- <span className="text-xs text-slate-500 ml-2 font-normal">({scopeLabel}, {numPairs} matched pair{numPairs !== 1 ? "s" : ""})</span>
745
- </h3>
746
- <p className="text-xs text-slate-400 mt-1">
747
- Per-dimension cross-correlation between action<sub>d</sub>(t) and Δstate<sub>d</sub>(t+lag), aggregated as
748
- <span className="text-orange-400"> max</span>, <span className="text-slate-200">mean</span>, and
749
- <span className="text-blue-400"> min</span> across all matched action–state pairs.
750
- The <span className="text-orange-400">peak lag</span> reveals the effective control delay — the time between
751
- when an action is commanded and when the corresponding state changes.
752
- <br />
753
- <span className="text-slate-500">
754
- Central to ACT (Zhao et al., 2023 — action chunking compensates for delay),
755
- Real-Time Chunking (RTC, 2024), and Training-Time RTC (Biza et al., 2025) — all address
756
- the timing mismatch between commanded actions and observed state changes.
757
- </span>
758
- </p>
 
 
 
 
759
  </div>
760
 
761
  {meanPeakLag !== 0 && (
@@ -775,12 +867,12 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
775
  </div>
776
  )}
777
 
778
- <div className="h-56">
779
  <ResponsiveContainer width="100%" height="100%">
780
  <LineChart data={ccData} margin={{ top: 8, right: 16, left: 0, bottom: 16 }}>
781
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
782
  <XAxis dataKey="lag" stroke="#94a3b8"
783
- label={{ value: "Lag (steps)", position: "insideBottom", offset: -8, fill: "#94a3b8", fontSize: 11 }} />
784
  <YAxis stroke="#94a3b8" domain={[-0.5, 1]} />
785
  <Tooltip
786
  contentStyle={{ background: "#1e293b", border: "1px solid #475569", borderRadius: 6 }}
@@ -793,20 +885,20 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
793
  <Line dataKey={() => 0} stroke="#64748b" strokeDasharray="6 4" dot={false} name="zero" legendType="none" isAnimationActive={false} />
794
  </LineChart>
795
  </ResponsiveContainer>
796
- </div>
797
 
798
  <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
799
  <div className="flex items-center gap-1.5">
800
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-orange-500" />
801
- <span className="text-[11px] text-slate-400">max (peak: lag {maxPeakLag}, r={maxPeakCorr.toFixed(3)})</span>
802
- </div>
803
  <div className="flex items-center gap-1.5">
804
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-slate-400" />
805
- <span className="text-[11px] text-slate-400">mean (peak: lag {meanPeakLag}, r={meanPeakCorr.toFixed(3)})</span>
806
- </div>
807
  <div className="flex items-center gap-1.5">
808
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-blue-500" />
809
- <span className="text-[11px] text-slate-400">min (peak: lag {minPeakLag}, r={minPeakCorr.toFixed(3)})</span>
810
  </div>
811
  </div>
812
 
@@ -824,6 +916,7 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
824
  const BC_THRESHOLD = 5 / 9;
825
 
826
  function MultimodalitySection({ data }: { data: CrossEpisodeVarianceData }) {
 
827
  const { actionNames, timeBins, multimodality, numEpisodes } = data;
828
  if (!multimodality || multimodality.length === 0) return null;
829
 
@@ -843,8 +936,10 @@ function MultimodalitySection({ data }: { data: CrossEpisodeVarianceData }) {
843
  return { bimodalPct: pct, verdict: v };
844
  }, [multimodality]);
845
 
846
- const cellW = Math.max(6, Math.min(14, Math.floor(560 / numBins)));
847
- const cellH = Math.max(20, Math.min(36, Math.floor(300 / numDims)));
 
 
848
  const labelW = 100;
849
  const svgW = labelW + numBins * cellW + 60;
850
  const svgH = numDims * cellH + 40;
@@ -861,22 +956,26 @@ function MultimodalitySection({ data }: { data: CrossEpisodeVarianceData }) {
861
  return (
862
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
863
  <div>
864
- <h3 className="text-sm font-semibold text-slate-200">
865
- Multimodality Detection
866
- <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes sampled)</span>
867
- </h3>
868
- <p className="text-xs text-slate-400 mt-1">
869
- Bimodality coefficient (BC) per action dimension over episode progress.
870
- BC values above <span className="text-red-400">5/9 ≈ 0.556</span> suggest the action distribution at that point is bimodal —
871
- meaning demonstrators use <span className="text-red-400">multiple distinct strategies</span>. This directly answers:
872
- &quot;Do I need a generative policy (diffusion, flow-matching) or would MSE regression work?&quot;
873
- <br />
874
- <span className="text-slate-500">
875
- Grounded in Diffusion Policy (Chi et al., 2023 — diffusion handles multimodality natively),
876
- ACT (Zhao et al., 2023 — CVAE captures multiple modes). Extends the cross-episode variance heatmap above
877
- by distinguishing true multimodality from mere noise.
878
- </span>
879
- </p>
 
 
 
 
880
  </div>
881
 
882
  <div className="overflow-x-auto">
@@ -936,17 +1035,23 @@ const CLUSTER_COLORS = ["#f97316", "#3b82f6", "#22c55e", "#a855f7", "#eab308"];
936
 
937
  function TrajectoryClusteringSection({ data, numEpisodes }: { data: TrajectoryClustering; numEpisodes: number }) {
938
  const { entries, numClusters, clusterSizes, outlierCount } = data;
 
 
 
939
 
940
  const [showAll, setShowAll] = useState(false);
 
 
941
  const [rotX, setRotX] = useState(-0.4);
942
  const [rotY, setRotY] = useState(0.6);
943
  const dragRef = React.useRef<{ x: number; y: number; rx: number; ry: number } | null>(null);
 
944
 
945
  const sorted = useMemo(() =>
946
  [...entries].sort((a, b) => b.distFromCenter - a.distFromCenter),
947
  [entries]);
948
 
949
- const plotW = 500, plotH = 400;
950
  const cx = plotW / 2, cy = plotH / 2;
951
  const scale = Math.min(plotW, plotH) * 0.35;
952
 
@@ -984,14 +1089,23 @@ function TrajectoryClusteringSection({ data, numEpisodes }: { data: TrajectoryCl
984
 
985
  const onMouseDown = (ev: React.MouseEvent) => {
986
  dragRef.current = { x: ev.clientX, y: ev.clientY, rx: rotX, ry: rotY };
 
987
  };
988
  const onMouseMove = (ev: React.MouseEvent) => {
989
  if (!dragRef.current) return;
990
- setRotY(dragRef.current.ry + (ev.clientX - dragRef.current.x) * 0.005);
991
- setRotX(dragRef.current.rx + (ev.clientY - dragRef.current.y) * 0.005);
 
 
 
992
  };
993
  const onMouseUp = () => { dragRef.current = null; };
994
 
 
 
 
 
 
995
  // Axis lines
996
  const axisLines = useMemo(() => {
997
  const cosX = Math.cos(rotX), sinX = Math.sin(rotX);
@@ -1018,55 +1132,93 @@ function TrajectoryClusteringSection({ data, numEpisodes }: { data: TrajectoryCl
1018
  return (
1019
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
1020
  <div>
1021
- <h3 className="text-sm font-semibold text-slate-200">
1022
- Trajectory Clustering & Outlier Detection
1023
- <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes sampled)</span>
1024
- </h3>
1025
- <p className="text-xs text-slate-400 mt-1">
1026
- Episodes clustered by trajectory similarity: each episode&apos;s action trajectory is time-normalized, standardized,
1027
- and projected to 3D via PCA. K-means clustering (k selected by silhouette score) groups similar demonstrations.
1028
- <span className="text-red-400"> Outlier episodes</span> ({">"} from cluster center) may indicate recording errors,
1029
- failed demonstrations, or fundamentally different strategies worth reviewing.
1030
- <span className="text-yellow-400"> Imbalanced clusters</span> suggest multimodal demonstrations.
1031
- Drag to rotate.
1032
- <br />
1033
- <span className="text-slate-500">
1034
- Grounded in FAST-UMI-100K (Zhao et al., 2025 — automatic quality tools at scale),
1035
- &quot;Curating Demonstrations using Online Experience&quot; (Burns et al., 2025),
1036
- GVL (Mazzaglia et al., 2024), and SARM (Li et al., 2025).
1037
- </span>
1038
- </p>
 
 
 
 
1039
  </div>
1040
 
1041
  <div className="flex gap-4 flex-wrap">
1042
  <div className="flex-1 min-w-[340px]">
 
 
 
 
 
 
1043
  <svg
1044
  width={plotW} height={plotH}
1045
  className="block bg-slate-900/50 rounded cursor-grab active:cursor-grabbing select-none"
1046
- onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} onMouseLeave={onMouseUp}
 
1047
  >
1048
- {/* Axis lines */}
1049
  {axisLines.map(a => (
1050
  <React.Fragment key={a.label}>
1051
  <line x1={a.ox} y1={a.oy} x2={a.px} y2={a.py} stroke={a.color} strokeWidth={0.5} strokeDasharray="4 3" opacity={0.4} />
1052
  <text x={a.px} y={a.py} className="fill-slate-600" fontSize={9} textAnchor="middle" dominantBaseline="central">{a.label}</text>
1053
  </React.Fragment>
1054
  ))}
1055
- {/* Points sorted back→front */}
1056
  {sortedByDepth.map(({ sx, sy, depth, entry: e }, i) => {
1057
  const color = CLUSTER_COLORS[e.cluster % CLUSTER_COLORS.length];
1058
  const depthFade = 0.3 + 0.7 * ((depth + 1) / 2);
1059
- const r = e.isOutlier ? 5 : 2.5 + depthFade * 2;
 
1060
  return (
1061
- <circle key={i} cx={sx} cy={sy} r={r}
1062
- fill={e.isOutlier ? "transparent" : color}
1063
- stroke={e.isOutlier ? "#ef4444" : color}
1064
- strokeWidth={e.isOutlier ? 2 : 0}
1065
- opacity={e.isOutlier ? 1 : depthFade * 0.8}>
1066
- <title>{`ep ${e.episodeIndex} — cluster ${e.cluster}${e.isOutlier ? " (outlier)" : ""}, dist=${e.distFromCenter.toFixed(2)}`}</title>
1067
- </circle>
 
 
 
 
 
 
 
 
 
 
 
 
1068
  );
1069
  })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1070
  </svg>
1071
  </div>
1072
 
@@ -1088,7 +1240,7 @@ function TrajectoryClusteringSection({ data, numEpisodes }: { data: TrajectoryCl
1088
  </div>
1089
  )}
1090
  {imbalance > 0.5 && (
1091
- <p className="text-yellow-400 text-[11px]">
1092
  Clusters are imbalanced ({(imbalance * 100).toFixed(0)}% size ratio) — the dataset may contain multiple distinct strategies.
1093
  </p>
1094
  )}
@@ -1100,14 +1252,18 @@ function TrajectoryClusteringSection({ data, numEpisodes }: { data: TrajectoryCl
1100
  <p className="text-sm font-medium text-slate-200">
1101
  {showAll ? "All Episodes" : "Most Anomalous Episodes"} <span className="text-xs text-slate-500 font-normal">sorted by distance from cluster center</span>
1102
  </p>
1103
- <button onClick={() => setShowAll(v => !v)} className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
1104
- {showAll ? "Show top 15" : `Show all ${entries.length}`}
1105
- </button>
 
 
 
1106
  </div>
1107
  <div className="max-h-48 overflow-y-auto">
1108
  <table className="w-full text-xs">
1109
  <thead>
1110
  <tr className="text-slate-500 border-b border-slate-700">
 
1111
  <th className="text-left py-1 pr-3">Episode</th>
1112
  <th className="text-left py-1 pr-3">Cluster</th>
1113
  <th className="text-right py-1">Distance</th>
@@ -1116,6 +1272,7 @@ function TrajectoryClusteringSection({ data, numEpisodes }: { data: TrajectoryCl
1116
  <tbody>
1117
  {(showAll ? sorted : sorted.slice(0, 15)).map(e => (
1118
  <tr key={e.episodeIndex} className={`border-b border-slate-800/40 ${e.isOutlier ? "text-red-400" : "text-slate-300"}`}>
 
1119
  <td className="py-1 pr-3">ep {e.episodeIndex}{e.isOutlier ? " ⚠" : ""}</td>
1120
  <td className="py-1 pr-3">
1121
  <span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: CLUSTER_COLORS[e.cluster % CLUSTER_COLORS.length] }} />
@@ -1153,11 +1310,11 @@ const ActionInsightsPanel: React.FC<ActionInsightsPanelProps> = ({
1153
  return (
1154
  <div className="max-w-5xl mx-auto py-6 space-y-8">
1155
  <div className="flex items-center justify-between flex-wrap gap-4">
1156
- <div>
1157
- <h2 className="text-xl font-bold text-slate-100">Action Insights</h2>
1158
- <p className="text-sm text-slate-400 mt-1">
1159
- Data-driven analysis to guide action chunking, data quality assessment, and training configuration.
1160
- </p>
1161
  </div>
1162
  <div className="flex items-center gap-3 shrink-0">
1163
  <span className={`text-sm ${mode === "episode" ? "text-slate-100 font-medium" : "text-slate-500"}`}>Current Episode</span>
@@ -1174,24 +1331,18 @@ const ActionInsightsPanel: React.FC<ActionInsightsPanelProps> = ({
1174
  </div>
1175
  </div>
1176
 
1177
- <AutocorrelationSection data={flatChartData} fps={fps} agg={showAgg ? crossEpisodeData?.aggAutocorrelation : null} numEpisodes={crossEpisodeData?.numEpisodes} />
1178
- <ActionVelocitySection data={flatChartData} agg={showAgg ? crossEpisodeData?.aggVelocity : undefined} numEpisodes={crossEpisodeData?.numEpisodes} jerkyEpisodes={showAgg ? crossEpisodeData?.jerkyEpisodes : undefined} />
1179
 
1180
  {crossEpisodeData?.speedDistribution && crossEpisodeData.speedDistribution.length > 2 && (
1181
- <SpeedVarianceSection distribution={crossEpisodeData.speedDistribution} numEpisodes={crossEpisodeData.numEpisodes} />
1182
- )}
1183
- <StateActionAlignmentSection data={flatChartData} fps={fps} agg={showAgg ? crossEpisodeData?.aggAlignment : null} numEpisodes={crossEpisodeData?.numEpisodes} />
1184
- <VarianceHeatmap data={crossEpisodeData} loading={crossEpisodeLoading} />
1185
- {crossEpisodeData && <MultimodalitySection data={crossEpisodeData} />}
1186
- {crossEpisodeData?.trajectoryClustering && (
1187
- <TrajectoryClusteringSection data={crossEpisodeData.trajectoryClustering} numEpisodes={crossEpisodeData.numEpisodes} />
1188
- )}
1189
- {crossEpisodeData?.lowMovementEpisodes && (
1190
- <LowMovementSection episodes={crossEpisodeData.lowMovementEpisodes} />
1191
  )}
 
 
1192
  </div>
1193
  );
1194
  };
1195
 
1196
  export default ActionInsightsPanel;
 
1197
 
 
1
  "use client";
2
 
3
+ import React, { useMemo, useState, useCallback, useEffect, useRef } from "react";
4
+ import { useRouter, usePathname } from "next/navigation";
5
  import {
6
  LineChart,
7
  Line,
 
11
  ResponsiveContainer,
12
  Tooltip,
13
  } from "recharts";
14
+ import type { CrossEpisodeVarianceData, AggVelocityStat, AggAutocorrelation, SpeedDistEntry, JerkyEpisode, TrajectoryClustering, AggAlignment } from "@/app/[org]/[dataset]/[episode]/fetch-data";
15
+ import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
16
 
17
  const DELIMITER = " | ";
18
+
19
+ const FullscreenCtx = React.createContext(false);
20
+ const useIsFullscreen = () => React.useContext(FullscreenCtx);
21
+
22
+ function InfoToggle({ children }: { children: React.ReactNode }) {
23
+ const [open, setOpen] = useState(false);
24
+ return (
25
+ <>
26
+ <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">
27
+ <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>
28
+ </button>
29
+ {open && <div className="mt-1">{children}</div>}
30
+ </>
31
+ );
32
+ }
33
+
34
+ function FullscreenWrapper({ children }: { children: React.ReactNode }) {
35
+ const [fs, setFs] = useState(false);
36
+
37
+ useEffect(() => {
38
+ if (!fs) return;
39
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setFs(false); };
40
+ document.addEventListener("keydown", onKey);
41
+ return () => document.removeEventListener("keydown", onKey);
42
+ }, [fs]);
43
+
44
+ return (
45
+ <div className="relative">
46
+ <button
47
+ onClick={() => setFs(v => !v)}
48
+ 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"
49
+ title={fs ? "Exit fullscreen" : "Fullscreen"}
50
+ >
51
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
52
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
53
+ {fs ? (
54
+ <><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"/></>
55
+ ) : (
56
+ <><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"/></>
57
+ )}
58
+ </svg>
59
+ </button>
60
+ {fs ? (
61
+ <div className="fixed inset-0 z-50 bg-slate-950/95 overflow-auto p-6">
62
+ <button
63
+ onClick={() => setFs(false)}
64
+ 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"
65
+ title="Exit fullscreen (Esc)"
66
+ >
67
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
68
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
69
+ <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"/>
70
+ </svg>
71
+ </button>
72
+ <div className="max-w-7xl mx-auto"><FullscreenCtx.Provider value={true}>{children}</FullscreenCtx.Provider></div>
73
+ </div>
74
+ ) : children}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function FlagBtn({ id }: { id: number }) {
80
+ const { has, toggle } = useFlaggedEpisodes();
81
+ const flagged = has(id);
82
+ return (
83
+ <button onClick={() => toggle(id)} title={flagged ? "Unflag episode" : "Flag for review"}
84
+ className={`p-0.5 rounded transition-colors ${flagged ? "text-orange-400" : "text-slate-600 hover:text-slate-400"}`}>
85
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill={flagged ? "currentColor" : "none"}
86
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
87
+ <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" />
88
+ </svg>
89
+ </button>
90
+ );
91
+ }
92
+
93
+ function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
94
+ const { addMany } = useFlaggedEpisodes();
95
+ return (
96
+ <button onClick={() => addMany(ids)}
97
+ className="text-xs text-slate-500 hover:text-orange-400 transition-colors flex items-center gap-1">
98
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none"
99
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
100
+ <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" />
101
+ </svg>
102
+ {label ?? "Flag all"}
103
+ </button>
104
+ );
105
+ }
106
  const COLORS = [
107
  "#f97316", "#3b82f6", "#22c55e", "#ef4444", "#a855f7",
108
  "#eab308", "#06b6d4", "#ec4899", "#14b8a6", "#f59e0b",
 
150
  }
151
 
152
  function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<string, number>[]; fps: number; agg?: AggAutocorrelation | null; numEpisodes?: number }) {
153
+ const isFs = useIsFullscreen();
154
  const actionKeys = useMemo(() => (data.length > 0 ? getActionKeys(data[0]) : []), [data]);
155
  const maxLag = useMemo(() => Math.min(Math.floor(data.length / 2), 100), [data]);
156
 
 
176
  }, [data, actionKeys, maxLag, fps, agg]);
177
 
178
  const { chartData, suggestedChunk, shortKeys } = agg ?? fallback ?? { chartData: [], suggestedChunk: null, shortKeys: [] };
179
+ const isAgg = !!agg;
180
+ const numEpisodesLabel = isAgg ? ` (${numEpisodes} episodes sampled)` : " (current episode)";
181
+
182
+ const yDomain = useMemo(() => {
183
+ if (chartData.length === 0 || shortKeys.length === 0) return [-0.2, 1] as [number, number];
184
+ let min = Infinity;
185
+ for (const row of chartData) for (const k of shortKeys) {
186
+ const v = row[k];
187
+ if (typeof v === "number" && v < min) min = v;
188
+ }
189
+ const lo = Math.floor(Math.min(min, 0) * 10) / 10;
190
+ return [lo, 1] as [number, number];
191
+ }, [chartData, shortKeys]);
192
 
193
  if (shortKeys.length === 0) return <p className="text-slate-500 italic">No action columns found.</p>;
194
 
195
  return (
196
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
197
  <div>
198
+ <div className="flex items-center gap-2">
199
  <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>
200
+ <InfoToggle>
201
+ <p className="text-xs text-slate-400">
202
  Shows how correlated each action dimension is with itself over increasing time lags.
203
  Where autocorrelation drops below 0.5 suggests a <span className="text-orange-400 font-medium">natural action chunk boundary</span> — actions
204
  beyond this lag are essentially independent, so executing them open-loop offers diminishing returns.
 
208
  (Zhang et al., 2025 — arXiv:2507.09061, Theorem 1).
209
  </span>
210
  </p>
211
+ </InfoToggle>
212
+ </div>
213
  </div>
214
 
215
  {suggestedChunk && (
 
224
  </div>
225
  )}
226
 
227
+ <div className={isFs ? "h-[500px]" : "h-64"}>
228
  <ResponsiveContainer width="100%" height="100%">
229
+ <LineChart key={isAgg ? "agg" : "ep"} data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 16 }}>
230
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
231
  <XAxis
232
  dataKey="lag"
233
  stroke="#94a3b8"
234
+ label={{ value: "Lag (steps)", position: "insideBottom", offset: -8, fill: "#94a3b8", fontSize: 13 }}
235
  />
236
+ <YAxis stroke="#94a3b8" domain={yDomain} />
237
  <Tooltip
238
  contentStyle={{ background: "#1e293b", border: "1px solid #475569", borderRadius: 6 }}
239
  labelFormatter={(v) => `Lag ${v} (${(Number(v) / fps).toFixed(2)}s)`}
 
268
  {shortKeys.map((name, i) => (
269
  <div key={name} className="flex items-center gap-1.5">
270
  <span className="w-3 h-[3px] rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
271
+ <span className="text-xs text-slate-400">{name}</span>
272
  </div>
273
  ))}
274
  </div>
 
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.
 
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 */}
 
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>
 
426
  <p className="text-sm font-medium text-slate-200">
427
  Most Jerky Episodes <span className="text-xs text-slate-500 font-normal">sorted by mean |Δa|</span>
428
  </p>
429
+ <div className="flex items-center gap-3">
430
+ <FlagAllBtn ids={display.map(e => e.episodeIndex)} />
431
+ {episodes.length > 15 && (
432
+ <button onClick={() => setShowAll(v => !v)} className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
433
+ {showAll ? "Show top 15" : `Show all ${episodes.length}`}
434
+ </button>
435
+ )}
436
+ </div>
437
  </div>
438
  <div className="max-h-48 overflow-y-auto">
439
  <table className="w-full text-xs">
440
  <thead>
441
  <tr className="text-slate-500 border-b border-slate-700">
442
+ <th className="w-5 py-1" />
443
  <th className="text-left py-1 pr-3">Episode</th>
444
  <th className="text-right py-1">Mean |Δa|</th>
445
  </tr>
 
447
  <tbody>
448
  {display.map(e => (
449
  <tr key={e.episodeIndex} className="border-b border-slate-800/40 text-slate-300">
450
+ <td className="py-1"><FlagBtn id={e.episodeIndex} /></td>
451
  <td className="py-1 pr-3">ep {e.episodeIndex}</td>
452
  <td className="py-1 text-right tabular-nums">{e.meanAbsDelta.toFixed(4)}</td>
453
  </tr>
 
486
  );
487
  }
488
 
489
+ const isFs = useIsFullscreen();
490
  const { actionNames, timeBins, variance, numEpisodes } = data;
491
  const numDims = actionNames.length;
492
  const numBins = timeBins.length;
493
 
 
494
  const maxVar = Math.max(...variance.flat(), 1e-10);
495
 
496
+ const baseW = isFs ? 1000 : 560;
497
+ const baseH = isFs ? 500 : 300;
498
+ const cellW = Math.max(6, Math.min(isFs ? 24 : 14, Math.floor(baseW / numBins)));
499
+ const cellH = Math.max(20, Math.min(isFs ? 56 : 36, Math.floor(baseH / numDims)));
500
  const labelW = 100;
501
+ const svgW = labelW + numBins * cellW + 60;
502
+ const svgH = numDims * cellH + 40;
503
 
504
  function varColor(v: number): string {
505
  const t = Math.sqrt(v / maxVar); // sqrt for better visual spread
 
513
  return (
514
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
515
  <div>
516
+ <div className="flex items-center gap-2">
517
  <h3 className="text-sm font-semibold text-slate-200">
518
  Cross-Episode Action Variance
519
  <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes sampled)</span>
520
  </h3>
521
+ <InfoToggle>
522
+ <p className="text-xs text-slate-400">
523
  Shows how much each action dimension varies across episodes at each point in time (normalized 0–100%).
524
  <span className="text-orange-400"> High-variance regions</span> indicate multi-modal or inconsistent demonstrations —
525
  generative policies (diffusion, flow-matching) and action chunking help here by modeling multiple modes.
 
530
  exploratory coverage needed to prevent compounding errors (Section 4).
531
  </span>
532
  </p>
533
+ </InfoToggle>
534
+ </div>
535
  </div>
536
 
537
  <div className="overflow-x-auto">
 
635
  );
636
  }
637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  // ─── Demonstrator Speed Variance ────────────────────────────────
639
 
640
  function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: SpeedDistEntry[]; numEpisodes: number }) {
641
+ const isFs = useIsFullscreen();
642
  const { speeds, mean, std, cv, median, bins, lo, binW, maxBin, verdict } = useMemo(() => {
643
  const sp = distribution.map(d => d.speed).sort((a, b) => a - b);
644
  const m = sp.reduce((a, b) => a + b, 0) / sp.length;
 
662
 
663
  if (speeds.length < 3) return null;
664
 
665
+ const barH = isFs ? 250 : 100;
666
+ const barW = Math.max(8, Math.floor((isFs ? 900 : 500) / bins.length));
667
 
668
  return (
669
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
670
  <div>
671
+ <div className="flex items-center gap-2">
672
+ <h3 className="text-sm font-semibold text-slate-200">
673
+ Demonstrator Speed Variance
674
+ <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes)</span>
675
+ </h3>
676
+ <InfoToggle>
677
+ <p className="text-xs text-slate-400">
678
+ Distribution of average execution speed (mean ‖Δa<sub>t</sub>‖ per frame) across all episodes.
679
+ Different human demonstrators often execute at <span className="text-orange-400">different speeds</span>, creating
680
+ artificial multimodality in the action distribution that confuses the policy. A coefficient of variation (CV) above 0.3
681
+ strongly suggests normalizing trajectory speed before training.
682
+ <br />
683
+ <span className="text-slate-500">
684
+ Based on &quot;Is Diversity All You Need&quot; (AGI-Bot, 2025) which shows velocity normalization dramatically improves
685
+ fine-tuning success rate. Also relates to ACT (Zhao et al., 2023) and Pi0.5 (Physical Intelligence, 2025).
686
+ </span>
687
+ </p>
688
+ </InfoToggle>
689
+ </div>
690
  </div>
691
 
692
  <div className="flex gap-4">
 
738
  // ─── State–Action Temporal Alignment ────────────────────────────
739
 
740
  function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Record<string, number>[]; fps: number; agg?: AggAlignment | null; numEpisodes?: number }) {
741
+ const isFs = useIsFullscreen();
742
  const result = useMemo(() => {
743
  if (agg) return { ...agg, fromAgg: true };
744
  if (data.length < 10) return null;
 
827
  return (
828
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
829
  <div>
830
+ <div className="flex items-center gap-2">
831
+ <h3 className="text-sm font-semibold text-slate-200">
832
+ State–Action Temporal Alignment
833
+ <span className="text-xs text-slate-500 ml-2 font-normal">({scopeLabel}, {numPairs} matched pair{numPairs !== 1 ? "s" : ""})</span>
834
+ </h3>
835
+ <InfoToggle>
836
+ <p className="text-xs text-slate-400">
837
+ Per-dimension cross-correlation between action<sub>d</sub>(t) and Δstate<sub>d</sub>(t+lag), aggregated as
838
+ <span className="text-orange-400"> max</span>, <span className="text-slate-200">mean</span>, and
839
+ <span className="text-blue-400"> min</span> across all matched action–state pairs.
840
+ The <span className="text-orange-400">peak lag</span> reveals the effective control delay — the time between
841
+ when an action is commanded and when the corresponding state changes.
842
+ <br />
843
+ <span className="text-slate-500">
844
+ Central to ACT (Zhao et al., 2023 action chunking compensates for delay),
845
+ Real-Time Chunking (RTC, 2024), and Training-Time RTC (Biza et al., 2025) — all address
846
+ the timing mismatch between commanded actions and observed state changes.
847
+ </span>
848
+ </p>
849
+ </InfoToggle>
850
+ </div>
851
  </div>
852
 
853
  {meanPeakLag !== 0 && (
 
867
  </div>
868
  )}
869
 
870
+ <div className={isFs ? "h-[500px]" : "h-56"}>
871
  <ResponsiveContainer width="100%" height="100%">
872
  <LineChart data={ccData} margin={{ top: 8, right: 16, left: 0, bottom: 16 }}>
873
  <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
874
  <XAxis dataKey="lag" stroke="#94a3b8"
875
+ label={{ value: "Lag (steps)", position: "insideBottom", offset: -8, fill: "#94a3b8", fontSize: 13 }} />
876
  <YAxis stroke="#94a3b8" domain={[-0.5, 1]} />
877
  <Tooltip
878
  contentStyle={{ background: "#1e293b", border: "1px solid #475569", borderRadius: 6 }}
 
885
  <Line dataKey={() => 0} stroke="#64748b" strokeDasharray="6 4" dot={false} name="zero" legendType="none" isAnimationActive={false} />
886
  </LineChart>
887
  </ResponsiveContainer>
888
+ </div>
889
 
890
  <div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
891
  <div className="flex items-center gap-1.5">
892
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-orange-500" />
893
+ <span className="text-xs text-slate-400">max (peak: lag {maxPeakLag}, r={maxPeakCorr.toFixed(3)})</span>
894
+ </div>
895
  <div className="flex items-center gap-1.5">
896
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-slate-400" />
897
+ <span className="text-xs text-slate-400">mean (peak: lag {meanPeakLag}, r={meanPeakCorr.toFixed(3)})</span>
898
+ </div>
899
  <div className="flex items-center gap-1.5">
900
  <span className="w-3 h-[3px] rounded-full shrink-0 bg-blue-500" />
901
+ <span className="text-xs text-slate-400">min (peak: lag {minPeakLag}, r={minPeakCorr.toFixed(3)})</span>
902
  </div>
903
  </div>
904
 
 
916
  const BC_THRESHOLD = 5 / 9;
917
 
918
  function MultimodalitySection({ data }: { data: CrossEpisodeVarianceData }) {
919
+ const isFs = useIsFullscreen();
920
  const { actionNames, timeBins, multimodality, numEpisodes } = data;
921
  if (!multimodality || multimodality.length === 0) return null;
922
 
 
936
  return { bimodalPct: pct, verdict: v };
937
  }, [multimodality]);
938
 
939
+ const mBaseW = isFs ? 1000 : 560;
940
+ const mBaseH = isFs ? 500 : 300;
941
+ const cellW = Math.max(6, Math.min(isFs ? 24 : 14, Math.floor(mBaseW / numBins)));
942
+ const cellH = Math.max(20, Math.min(isFs ? 56 : 36, Math.floor(mBaseH / numDims)));
943
  const labelW = 100;
944
  const svgW = labelW + numBins * cellW + 60;
945
  const svgH = numDims * cellH + 40;
 
956
  return (
957
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
958
  <div>
959
+ <div className="flex items-center gap-2">
960
+ <h3 className="text-sm font-semibold text-slate-200">
961
+ Multimodality Detection
962
+ <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes sampled)</span>
963
+ </h3>
964
+ <InfoToggle>
965
+ <p className="text-xs text-slate-400">
966
+ Bimodality coefficient (BC) per action dimension over episode progress.
967
+ BC values above <span className="text-red-400">5/9 0.556</span> suggest the action distribution at that point is bimodal —
968
+ meaning demonstrators use <span className="text-red-400">multiple distinct strategies</span>. This directly answers:
969
+ &quot;Do I need a generative policy (diffusion, flow-matching) or would MSE regression work?&quot;
970
+ <br />
971
+ <span className="text-slate-500">
972
+ Grounded in Diffusion Policy (Chi et al., 2023 — diffusion handles multimodality natively),
973
+ ACT (Zhao et al., 2023 — CVAE captures multiple modes). Extends the cross-episode variance heatmap above
974
+ by distinguishing true multimodality from mere noise.
975
+ </span>
976
+ </p>
977
+ </InfoToggle>
978
+ </div>
979
  </div>
980
 
981
  <div className="overflow-x-auto">
 
1035
 
1036
  function TrajectoryClusteringSection({ data, numEpisodes }: { data: TrajectoryClustering; numEpisodes: number }) {
1037
  const { entries, numClusters, clusterSizes, outlierCount } = data;
1038
+ const isFs = useIsFullscreen();
1039
+ const router = useRouter();
1040
+ const pathname = usePathname();
1041
 
1042
  const [showAll, setShowAll] = useState(false);
1043
+ const [showLabels, setShowLabels] = useState(false);
1044
+ const [hoveredEp, setHoveredEp] = useState<number | null>(null);
1045
  const [rotX, setRotX] = useState(-0.4);
1046
  const [rotY, setRotY] = useState(0.6);
1047
  const dragRef = React.useRef<{ x: number; y: number; rx: number; ry: number } | null>(null);
1048
+ const didDrag = useRef(false);
1049
 
1050
  const sorted = useMemo(() =>
1051
  [...entries].sort((a, b) => b.distFromCenter - a.distFromCenter),
1052
  [entries]);
1053
 
1054
+ const plotW = isFs ? 900 : 500, plotH = isFs ? 700 : 400;
1055
  const cx = plotW / 2, cy = plotH / 2;
1056
  const scale = Math.min(plotW, plotH) * 0.35;
1057
 
 
1089
 
1090
  const onMouseDown = (ev: React.MouseEvent) => {
1091
  dragRef.current = { x: ev.clientX, y: ev.clientY, rx: rotX, ry: rotY };
1092
+ didDrag.current = false;
1093
  };
1094
  const onMouseMove = (ev: React.MouseEvent) => {
1095
  if (!dragRef.current) return;
1096
+ const dx = ev.clientX - dragRef.current.x;
1097
+ const dy = ev.clientY - dragRef.current.y;
1098
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag.current = true;
1099
+ setRotY(dragRef.current.ry + dx * 0.005);
1100
+ setRotX(dragRef.current.rx + dy * 0.005);
1101
  };
1102
  const onMouseUp = () => { dragRef.current = null; };
1103
 
1104
+ const navigateToEpisode = useCallback((epIdx: number) => {
1105
+ const base = pathname.replace(/\/episode_\d+$/, "");
1106
+ router.push(`${base}/episode_${epIdx}`);
1107
+ }, [pathname, router]);
1108
+
1109
  // Axis lines
1110
  const axisLines = useMemo(() => {
1111
  const cosX = Math.cos(rotX), sinX = Math.sin(rotX);
 
1132
  return (
1133
  <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
1134
  <div>
1135
+ <div className="flex items-center gap-2">
1136
+ <h3 className="text-sm font-semibold text-slate-200">
1137
+ Trajectory Clustering & Outlier Detection
1138
+ <span className="text-xs text-slate-500 ml-2 font-normal">({numEpisodes} episodes sampled)</span>
1139
+ </h3>
1140
+ <InfoToggle>
1141
+ <p className="text-xs text-slate-400">
1142
+ Episodes clustered by trajectory similarity: each episode&apos;s action trajectory is time-normalized, standardized,
1143
+ and projected to 3D via PCA. K-means clustering (k selected by silhouette score) groups similar demonstrations.
1144
+ <span className="text-red-400"> Outlier episodes</span> ({">"} from cluster center) may indicate recording errors,
1145
+ failed demonstrations, or fundamentally different strategies worth reviewing.
1146
+ <span className="text-yellow-400"> Imbalanced clusters</span> suggest multimodal demonstrations.
1147
+ Drag to rotate.
1148
+ <br />
1149
+ <span className="text-slate-500">
1150
+ Grounded in FAST-UMI-100K (Zhao et al., 2025 automatic quality tools at scale),
1151
+ &quot;Curating Demonstrations using Online Experience&quot; (Burns et al., 2025),
1152
+ GVL (Mazzaglia et al., 2024), and SARM (Li et al., 2025).
1153
+ </span>
1154
+ </p>
1155
+ </InfoToggle>
1156
+ </div>
1157
  </div>
1158
 
1159
  <div className="flex gap-4 flex-wrap">
1160
  <div className="flex-1 min-w-[340px]">
1161
+ <div className="flex justify-end mb-1">
1162
+ <button onClick={() => setShowLabels(v => !v)}
1163
+ className={`text-xs px-2 py-0.5 rounded transition-colors ${showLabels ? "bg-orange-500/20 text-orange-400 border border-orange-500/40" : "text-slate-500 hover:text-slate-300 border border-slate-700"}`}>
1164
+ {showLabels ? "Hide episodes" : "Show episodes"}
1165
+ </button>
1166
+ </div>
1167
  <svg
1168
  width={plotW} height={plotH}
1169
  className="block bg-slate-900/50 rounded cursor-grab active:cursor-grabbing select-none"
1170
+ onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp}
1171
+ onMouseLeave={(ev) => { onMouseUp(); setHoveredEp(null); }}
1172
  >
 
1173
  {axisLines.map(a => (
1174
  <React.Fragment key={a.label}>
1175
  <line x1={a.ox} y1={a.oy} x2={a.px} y2={a.py} stroke={a.color} strokeWidth={0.5} strokeDasharray="4 3" opacity={0.4} />
1176
  <text x={a.px} y={a.py} className="fill-slate-600" fontSize={9} textAnchor="middle" dominantBaseline="central">{a.label}</text>
1177
  </React.Fragment>
1178
  ))}
 
1179
  {sortedByDepth.map(({ sx, sy, depth, entry: e }, i) => {
1180
  const color = CLUSTER_COLORS[e.cluster % CLUSTER_COLORS.length];
1181
  const depthFade = 0.3 + 0.7 * ((depth + 1) / 2);
1182
+ const isHovered = hoveredEp === e.episodeIndex;
1183
+ const r = isHovered ? 7 : e.isOutlier ? 5 : 2.5 + depthFade * 2;
1184
  return (
1185
+ <g key={i}
1186
+ onMouseEnter={() => setHoveredEp(e.episodeIndex)}
1187
+ onMouseLeave={() => setHoveredEp(null)}
1188
+ onClick={() => { if (!didDrag.current) navigateToEpisode(e.episodeIndex); }}
1189
+ className="cursor-pointer"
1190
+ >
1191
+ <circle cx={sx} cy={sy} r={r}
1192
+ fill={e.isOutlier ? "transparent" : color}
1193
+ stroke={isHovered ? "#fff" : e.isOutlier ? "#ef4444" : color}
1194
+ strokeWidth={isHovered ? 2 : e.isOutlier ? 2 : 0}
1195
+ opacity={e.isOutlier ? 1 : isHovered ? 1 : depthFade * 0.8}
1196
+ />
1197
+ {(showLabels || isHovered) && (
1198
+ <text x={sx} y={sy - r - 3} textAnchor="middle" fontSize={isHovered ? 11 : 8}
1199
+ className={isHovered ? "fill-white font-semibold" : "fill-slate-400"} pointerEvents="none">
1200
+ {e.episodeIndex}
1201
+ </text>
1202
+ )}
1203
+ </g>
1204
  );
1205
  })}
1206
+ {hoveredEp !== null && (() => {
1207
+ const p = sortedByDepth.find(p => p.entry.episodeIndex === hoveredEp);
1208
+ if (!p) return null;
1209
+ const e = p.entry;
1210
+ return (
1211
+ <g pointerEvents="none">
1212
+ <rect x={p.sx + 10} y={p.sy - 30} width={130} height={40} rx={4} fill="#0f172a" stroke="#334155" strokeWidth={1} opacity={0.95} />
1213
+ <text x={p.sx + 16} y={p.sy - 16} fontSize={10} className="fill-slate-200 font-medium">
1214
+ Episode {e.episodeIndex}
1215
+ </text>
1216
+ <text x={p.sx + 16} y={p.sy - 2} fontSize={9} className="fill-slate-400">
1217
+ cluster {e.cluster} · dist {e.distFromCenter.toFixed(2)}{e.isOutlier ? " · outlier" : ""}
1218
+ </text>
1219
+ </g>
1220
+ );
1221
+ })()}
1222
  </svg>
1223
  </div>
1224
 
 
1240
  </div>
1241
  )}
1242
  {imbalance > 0.5 && (
1243
+ <p className="text-yellow-400 text-xs">
1244
  Clusters are imbalanced ({(imbalance * 100).toFixed(0)}% size ratio) — the dataset may contain multiple distinct strategies.
1245
  </p>
1246
  )}
 
1252
  <p className="text-sm font-medium text-slate-200">
1253
  {showAll ? "All Episodes" : "Most Anomalous Episodes"} <span className="text-xs text-slate-500 font-normal">sorted by distance from cluster center</span>
1254
  </p>
1255
+ <div className="flex items-center gap-3">
1256
+ <FlagAllBtn ids={entries.filter(e => e.isOutlier).map(e => e.episodeIndex)} label="Flag outliers" />
1257
+ <button onClick={() => setShowAll(v => !v)} className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
1258
+ {showAll ? "Show top 15" : `Show all ${entries.length}`}
1259
+ </button>
1260
+ </div>
1261
  </div>
1262
  <div className="max-h-48 overflow-y-auto">
1263
  <table className="w-full text-xs">
1264
  <thead>
1265
  <tr className="text-slate-500 border-b border-slate-700">
1266
+ <th className="w-5 py-1" />
1267
  <th className="text-left py-1 pr-3">Episode</th>
1268
  <th className="text-left py-1 pr-3">Cluster</th>
1269
  <th className="text-right py-1">Distance</th>
 
1272
  <tbody>
1273
  {(showAll ? sorted : sorted.slice(0, 15)).map(e => (
1274
  <tr key={e.episodeIndex} className={`border-b border-slate-800/40 ${e.isOutlier ? "text-red-400" : "text-slate-300"}`}>
1275
+ <td className="py-1"><FlagBtn id={e.episodeIndex} /></td>
1276
  <td className="py-1 pr-3">ep {e.episodeIndex}{e.isOutlier ? " ⚠" : ""}</td>
1277
  <td className="py-1 pr-3">
1278
  <span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: CLUSTER_COLORS[e.cluster % CLUSTER_COLORS.length] }} />
 
1310
  return (
1311
  <div className="max-w-5xl mx-auto py-6 space-y-8">
1312
  <div className="flex items-center justify-between flex-wrap gap-4">
1313
+ <div>
1314
+ <h2 className="text-xl font-bold text-slate-100">Action Insights</h2>
1315
+ <p className="text-sm text-slate-400 mt-1">
1316
+ Data-driven analysis to guide action chunking, data quality assessment, and training configuration.
1317
+ </p>
1318
  </div>
1319
  <div className="flex items-center gap-3 shrink-0">
1320
  <span className={`text-sm ${mode === "episode" ? "text-slate-100 font-medium" : "text-slate-500"}`}>Current Episode</span>
 
1331
  </div>
1332
  </div>
1333
 
1334
+ <FullscreenWrapper><AutocorrelationSection data={flatChartData} fps={fps} agg={showAgg ? crossEpisodeData?.aggAutocorrelation : null} numEpisodes={crossEpisodeData?.numEpisodes} /></FullscreenWrapper>
1335
+ <FullscreenWrapper><StateActionAlignmentSection data={flatChartData} fps={fps} agg={showAgg ? crossEpisodeData?.aggAlignment : null} numEpisodes={crossEpisodeData?.numEpisodes} /></FullscreenWrapper>
1336
 
1337
  {crossEpisodeData?.speedDistribution && crossEpisodeData.speedDistribution.length > 2 && (
1338
+ <FullscreenWrapper><SpeedVarianceSection distribution={crossEpisodeData.speedDistribution} numEpisodes={crossEpisodeData.numEpisodes} /></FullscreenWrapper>
 
 
 
 
 
 
 
 
 
1339
  )}
1340
+ <FullscreenWrapper><VarianceHeatmap data={crossEpisodeData} loading={crossEpisodeLoading} /></FullscreenWrapper>
1341
+ {crossEpisodeData && <FullscreenWrapper><MultimodalitySection data={crossEpisodeData} /></FullscreenWrapper>}
1342
  </div>
1343
  );
1344
  };
1345
 
1346
  export default ActionInsightsPanel;
1347
+ export { ActionVelocitySection, TrajectoryClusteringSection, FullscreenWrapper };
1348
 
src/components/data-recharts.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { useEffect, useState } from "react";
4
  import { useTime } from "../context/time-context";
5
  import {
6
  LineChart,
@@ -30,29 +30,72 @@ const CHART_COLORS = [
30
  "#6366f1", "#84cc16",
31
  ];
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  export const DataRecharts = React.memo(
34
  ({ data, onChartsReady }: DataGraphProps) => {
35
- // Shared hoveredTime for all graphs
36
  const [hoveredTime, setHoveredTime] = useState<number | null>(null);
 
37
 
38
  if (!Array.isArray(data) || data.length === 0) return null;
39
 
40
  useEffect(() => {
41
- if (typeof onChartsReady === "function") {
42
- onChartsReady();
43
- }
44
  }, [onChartsReady]);
45
 
 
 
46
  return (
47
- <div className="grid md:grid-cols-2 grid-cols-1 gap-4">
48
- {data.map((group, idx) => (
49
- <SingleDataGraph
50
- key={idx}
51
- data={group}
52
- hoveredTime={hoveredTime}
53
- setHoveredTime={setHoveredTime}
54
- />
55
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  </div>
57
  );
58
  },
@@ -64,10 +107,12 @@ const SingleDataGraph = React.memo(
64
  data,
65
  hoveredTime,
66
  setHoveredTime,
 
67
  }: {
68
  data: ChartRow[];
69
  hoveredTime: number | null;
70
  setHoveredTime: (t: number | null) => void;
 
71
  }) => {
72
  const { currentTime, setCurrentTime } = useTime();
73
  function flattenRow(row: Record<string, number | Record<string, number>>, prefix = ""): Record<string, number> {
@@ -205,7 +250,7 @@ const SingleDataGraph = React.memo(
205
  className="size-3"
206
  style={{ accentColor: color }}
207
  />
208
- <span className="text-[11px] font-semibold text-slate-200">{group}</span>
209
  </label>
210
  <div className="pl-5 flex flex-col gap-0.5 mt-0.5">
211
  {children.map((key) => {
@@ -219,8 +264,8 @@ const SingleDataGraph = React.memo(
219
  className="size-2.5"
220
  style={{ accentColor: color }}
221
  />
222
- <span className={`text-[10px] ${visibleKeys.includes(key) ? "text-slate-300" : "text-slate-500"}`}>{label}</span>
223
- <span className={`text-[10px] font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}>
224
  {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "–"}
225
  </span>
226
  </label>
@@ -241,8 +286,8 @@ const SingleDataGraph = React.memo(
241
  className="size-3"
242
  style={{ accentColor: color }}
243
  />
244
- <span className={`text-[11px] ${visibleKeys.includes(key) ? "text-slate-200" : "text-slate-500"}`}>{key}</span>
245
- <span className={`text-[10px] font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}>
246
  {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "–"}
247
  </span>
248
  </label>
@@ -270,7 +315,7 @@ const SingleDataGraph = React.memo(
270
  {chartTitle && (
271
  <p className="text-xs font-medium text-slate-300 mb-1 px-1 truncate" title={chartTitle}>{chartTitle}</p>
272
  )}
273
- <div className="w-full h-72" onMouseLeave={handleMouseLeave}>
274
  <ResponsiveContainer width="100%" height="100%">
275
  <LineChart
276
  data={chartData}
@@ -292,14 +337,14 @@ const SingleDataGraph = React.memo(
292
  ]}
293
  tickFormatter={(v: number) => `${v.toFixed(1)}s`}
294
  stroke="#64748b"
295
- tick={{ fontSize: 10, fill: "#94a3b8" }}
296
  minTickGap={30}
297
  allowDataOverflow={true}
298
  />
299
  <YAxis
300
  domain={["auto", "auto"]}
301
  stroke="#64748b"
302
- tick={{ fontSize: 10, fill: "#94a3b8" }}
303
  width={45}
304
  allowDataOverflow={true}
305
  />
 
1
  "use client";
2
 
3
+ import { useEffect, useState, useCallback } from "react";
4
  import { useTime } from "../context/time-context";
5
  import {
6
  LineChart,
 
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 = {};
39
+ for (const group of data) {
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
+ }
47
+ merged.push(row);
48
+ }
49
+ return merged;
50
+ }
51
+
52
  export const DataRecharts = React.memo(
53
  ({ data, onChartsReady }: DataGraphProps) => {
 
54
  const [hoveredTime, setHoveredTime] = useState<number | null>(null);
55
+ const [expanded, setExpanded] = useState(false);
56
 
57
  if (!Array.isArray(data) || data.length === 0) return null;
58
 
59
  useEffect(() => {
60
+ if (typeof onChartsReady === "function") onChartsReady();
 
 
61
  }, [onChartsReady]);
62
 
63
+ const combinedData = useMemo(() => expanded ? mergeGroups(data) : [], [data, expanded]);
64
+
65
  return (
66
+ <div>
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"}
86
+ </button>
87
+ </div>
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
+ )}
99
  </div>
100
  );
101
  },
 
107
  data,
108
  hoveredTime,
109
  setHoveredTime,
110
+ tall,
111
  }: {
112
  data: ChartRow[];
113
  hoveredTime: number | null;
114
  setHoveredTime: (t: number | null) => void;
115
+ tall?: boolean;
116
  }) => {
117
  const { currentTime, setCurrentTime } = useTime();
118
  function flattenRow(row: Record<string, number | Record<string, number>>, prefix = ""): Record<string, number> {
 
250
  className="size-3"
251
  style={{ accentColor: color }}
252
  />
253
+ <span className="text-xs font-semibold text-slate-200">{group}</span>
254
  </label>
255
  <div className="pl-5 flex flex-col gap-0.5 mt-0.5">
256
  {children.map((key) => {
 
264
  className="size-2.5"
265
  style={{ accentColor: color }}
266
  />
267
+ <span className={`text-xs ${visibleKeys.includes(key) ? "text-slate-300" : "text-slate-500"}`}>{label}</span>
268
+ <span className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}>
269
  {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "–"}
270
  </span>
271
  </label>
 
286
  className="size-3"
287
  style={{ accentColor: color }}
288
  />
289
+ <span className={`text-xs ${visibleKeys.includes(key) ? "text-slate-200" : "text-slate-500"}`}>{key}</span>
290
+ <span className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}>
291
  {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "–"}
292
  </span>
293
  </label>
 
315
  {chartTitle && (
316
  <p className="text-xs font-medium text-slate-300 mb-1 px-1 truncate" title={chartTitle}>{chartTitle}</p>
317
  )}
318
+ <div className={`w-full ${tall ? "h-[500px]" : "h-72"}`} onMouseLeave={handleMouseLeave}>
319
  <ResponsiveContainer width="100%" height="100%">
320
  <LineChart
321
  data={chartData}
 
337
  ]}
338
  tickFormatter={(v: number) => `${v.toFixed(1)}s`}
339
  stroke="#64748b"
340
+ tick={{ fontSize: 12, fill: "#94a3b8" }}
341
  minTickGap={30}
342
  allowDataOverflow={true}
343
  />
344
  <YAxis
345
  domain={["auto", "auto"]}
346
  stroke="#64748b"
347
+ tick={{ fontSize: 12, fill: "#94a3b8" }}
348
  width={45}
349
  allowDataOverflow={true}
350
  />
src/components/filtering-panel.tsx ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useMemo, useCallback } from "react";
4
+ import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
5
+ import type {
6
+ CrossEpisodeVarianceData,
7
+ LowMovementEpisode,
8
+ EpisodeLengthStats,
9
+ EpisodeLengthInfo,
10
+ } from "@/app/[org]/[dataset]/[episode]/fetch-data";
11
+ import { ActionVelocitySection, TrajectoryClusteringSection, FullscreenWrapper } from "@/components/action-insights-panel";
12
+
13
+ // ─── Shared small components ─────────────────────────────────────
14
+
15
+ 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
+ );
27
+ }
28
+
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>
40
+ );
41
+ }
42
+
43
+ // ─── Auto-Prune ──────────────────────────────────────────────────
44
+
45
+ function AutoPruneSection({ data }: { data: CrossEpisodeVarianceData }) {
46
+ const { addMany } = useFlaggedEpisodes();
47
+ const [pct, setPct] = useState(5);
48
+ const [copied, setCopied] = useState(false);
49
+
50
+ const result = useMemo(() => {
51
+ const allIds = new Set<number>();
52
+ const movementMap = new Map<number, number>();
53
+ const jerkyMap = new Map<number, number>();
54
+ const clusterMap = new Map<number, number>();
55
+
56
+ for (const e of data.lowMovementEpisodes) { allIds.add(e.episodeIndex); movementMap.set(e.episodeIndex, e.totalMovement); }
57
+ for (const e of data.jerkyEpisodes) { allIds.add(e.episodeIndex); jerkyMap.set(e.episodeIndex, e.meanAbsDelta); }
58
+ if (data.trajectoryClustering) {
59
+ for (const e of data.trajectoryClustering.entries) { allIds.add(e.episodeIndex); clusterMap.set(e.episodeIndex, e.distFromCenter); }
60
+ }
61
+ if (allIds.size === 0) return null;
62
+
63
+ const normalize = (map: Map<number, number>, invert: boolean) => {
64
+ const vals = [...map.values()];
65
+ if (vals.length === 0) return map;
66
+ const min = Math.min(...vals), max = Math.max(...vals);
67
+ const range = max - min || 1;
68
+ const out = new Map<number, number>();
69
+ for (const [k, v] of map) out.set(k, invert ? 1 - (v - min) / range : (v - min) / range);
70
+ return out;
71
+ };
72
+
73
+ const movNorm = normalize(movementMap, true);
74
+ const jerkNorm = normalize(jerkyMap, false);
75
+ const clustNorm = normalize(clusterMap, false);
76
+
77
+ const scored = [...allIds].map(id => {
78
+ let score = 0, weights = 0;
79
+ if (movNorm.has(id)) { score += movNorm.get(id)!; weights++; }
80
+ if (jerkNorm.has(id)) { score += jerkNorm.get(id)!; weights++; }
81
+ if (clustNorm.has(id)) { score += clustNorm.get(id)!; weights++; }
82
+ return { id, score: weights > 0 ? score / weights : 0 };
83
+ }).sort((a, b) => b.score - a.score);
84
+
85
+ const n = Math.max(1, Math.round(scored.length * pct / 100));
86
+ return { scored, pruneIds: scored.slice(0, n).map(s => s.id), total: scored.length };
87
+ }, [data, pct]);
88
+
89
+ if (!result) return null;
90
+
91
+ const handleCopy = () => {
92
+ navigator.clipboard.writeText(result.pruneIds.join(", "));
93
+ setCopied(true);
94
+ setTimeout(() => setCopied(false), 1500);
95
+ };
96
+
97
+ return (
98
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-orange-500/30 space-y-4">
99
+ <div>
100
+ <h3 className="text-sm font-semibold text-orange-400">Auto-Prune</h3>
101
+ <p className="text-xs text-slate-400 mt-1">
102
+ Automatically identify the worst episodes based on a composite score combining:
103
+ low movement (⅓), jerkiness (⅓), and cluster outlier distance (⅓).
104
+ Each metric is min-max normalized to [0,1] and averaged.
105
+ </p>
106
+ </div>
107
+
108
+ <div className="flex items-center gap-4">
109
+ <label className="text-xs text-slate-400 shrink-0">Prune worst</label>
110
+ <input type="range" min={1} max={50} value={pct} onChange={e => setPct(Number(e.target.value))}
111
+ className="flex-1 h-1.5 rounded appearance-none bg-slate-700 accent-orange-500" />
112
+ <span className="text-sm font-bold text-orange-400 tabular-nums w-10 text-right">{pct}%</span>
113
+ </div>
114
+
115
+ <div className="flex items-center justify-between">
116
+ <p className="text-xs text-slate-400">
117
+ {result.pruneIds.length} episode{result.pruneIds.length !== 1 ? "s" : ""} out of {result.total} scored
118
+ </p>
119
+ <div className="flex items-center gap-2">
120
+ <button onClick={handleCopy}
121
+ className="text-xs text-slate-400 hover:text-slate-200 transition-colors flex items-center gap-1">
122
+ {copied ? (
123
+ <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>
124
+ ) : (
125
+ <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>
126
+ )}
127
+ Copy IDs
128
+ </button>
129
+ <button onClick={() => addMany(result.pruneIds)}
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 all {result.pruneIds.length}
132
+ </button>
133
+ </div>
134
+ </div>
135
+
136
+ <div className="bg-slate-900/70 rounded-md border border-slate-700 p-3 max-h-32 overflow-y-auto">
137
+ <p className="text-xs text-slate-300 tabular-nums leading-relaxed">
138
+ {result.pruneIds.sort((a, b) => a - b).join(", ")}
139
+ </p>
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // ─── Lowest-Movement Episodes ────────────────────────────────────
146
+
147
+ function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
148
+ if (episodes.length === 0) return null;
149
+ const maxMovement = Math.max(...episodes.map(e => e.totalMovement), 1e-10);
150
+
151
+ return (
152
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-3">
153
+ <div className="flex items-center justify-between">
154
+ <h3 className="text-sm font-semibold text-slate-200">Lowest-Movement Episodes</h3>
155
+ <FlagAllBtn ids={episodes.map(e => e.episodeIndex)} />
156
+ </div>
157
+ <p className="text-xs text-slate-400">
158
+ 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.
159
+ </p>
160
+ <div className="grid gap-2" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}>
161
+ {episodes.map(ep => (
162
+ <div key={ep.episodeIndex} className="bg-slate-900/50 rounded-md px-3 py-2 flex items-center gap-3">
163
+ <FlagBtn id={ep.episodeIndex} />
164
+ <span className="text-xs text-slate-300 font-medium shrink-0">ep {ep.episodeIndex}</span>
165
+ <div className="flex-1 min-w-0">
166
+ <div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
167
+ <div className="h-full rounded-full"
168
+ style={{
169
+ width: `${Math.max(2, (ep.totalMovement / maxMovement) * 100)}%`,
170
+ background: ep.totalMovement / maxMovement < 0.15 ? "#ef4444" : ep.totalMovement / maxMovement < 0.4 ? "#eab308" : "#22c55e",
171
+ }} />
172
+ </div>
173
+ </div>
174
+ <span className="text-xs text-slate-500 tabular-nums shrink-0">{ep.totalMovement.toFixed(2)}</span>
175
+ </div>
176
+ ))}
177
+ </div>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ // ─── Episode Length Filter ────────────────────────────────────────
183
+
184
+ function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
185
+ const { addMany } = useFlaggedEpisodes();
186
+ const globalMin = useMemo(() => Math.min(...episodes.map((e) => e.lengthSeconds)), [episodes]);
187
+ const globalMax = useMemo(() => Math.max(...episodes.map((e) => e.lengthSeconds)), [episodes]);
188
+
189
+ const [rangeMin, setRangeMin] = useState(globalMin);
190
+ const [rangeMax, setRangeMax] = useState(globalMax);
191
+
192
+ const outsideIds = useMemo(() =>
193
+ episodes.filter((e) => e.lengthSeconds < rangeMin || e.lengthSeconds > rangeMax)
194
+ .map((e) => e.episodeIndex).sort((a, b) => a - b),
195
+ [episodes, rangeMin, rangeMax]);
196
+
197
+ const rangeChanged = rangeMin > globalMin || rangeMax < globalMax;
198
+ const step = Math.max(0.01, Math.round((globalMax - globalMin) * 0.001 * 100) / 100) || 0.01;
199
+
200
+ return (
201
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
202
+ <h3 className="text-sm font-semibold text-slate-200">Episode Length Filter</h3>
203
+
204
+ <div className="space-y-2">
205
+ <div className="flex items-center justify-between text-xs text-slate-400">
206
+ <span className="tabular-nums">{rangeMin.toFixed(1)}s</span>
207
+ <span className="tabular-nums">{rangeMax.toFixed(1)}s</span>
208
+ </div>
209
+ <div className="relative h-5">
210
+ <div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
211
+ <div className="absolute top-1/2 -translate-y-1/2 h-1 rounded bg-orange-500"
212
+ style={{
213
+ left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
214
+ right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
215
+ }} />
216
+ <input type="range" min={globalMin} max={globalMax} step={step} value={rangeMin}
217
+ onChange={(e) => setRangeMin(Math.min(Number(e.target.value), rangeMax))}
218
+ 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" />
219
+ <input type="range" min={globalMin} max={globalMax} step={step} value={rangeMax}
220
+ onChange={(e) => setRangeMax(Math.max(Number(e.target.value), rangeMin))}
221
+ 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" />
222
+ </div>
223
+ </div>
224
+
225
+ {rangeChanged && (
226
+ <div className="flex items-center justify-between">
227
+ <span className="text-xs text-slate-400">
228
+ {outsideIds.length} episode{outsideIds.length !== 1 ? "s" : ""} outside range
229
+ </span>
230
+ {outsideIds.length > 0 && (
231
+ <button onClick={() => addMany(outsideIds)}
232
+ 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">
233
+ Flag {outsideIds.length} outside range
234
+ </button>
235
+ )}
236
+ </div>
237
+ )}
238
+ </div>
239
+ );
240
+ }
241
+
242
+ // ─── Main Filtering Panel ────────────────────────────────────────
243
+
244
+ interface FilteringPanelProps {
245
+ repoId: string;
246
+ crossEpisodeData: CrossEpisodeVarianceData | null;
247
+ crossEpisodeLoading: boolean;
248
+ episodeLengthStats: EpisodeLengthStats | null;
249
+ flatChartData: Record<string, number>[];
250
+ fps: number;
251
+ onViewFlaggedEpisodes?: () => void;
252
+ }
253
+
254
+ function FlaggedIdsCopyBar({ repoId, onViewEpisodes }: { repoId: string; onViewEpisodes?: () => void }) {
255
+ const { flagged, count, clear } = useFlaggedEpisodes();
256
+ const [copied, setCopied] = useState(false);
257
+
258
+ const ids = useMemo(() => [...flagged].sort((a, b) => a - b), [flagged]);
259
+ const idStr = ids.join(", ");
260
+
261
+ const handleCopy = useCallback(() => {
262
+ navigator.clipboard.writeText(idStr);
263
+ setCopied(true);
264
+ setTimeout(() => setCopied(false), 1500);
265
+ }, [idStr]);
266
+
267
+ if (count === 0) return null;
268
+
269
+ return (
270
+ <div className="bg-slate-800/60 rounded-lg p-4 border border-orange-500/30 space-y-3">
271
+ <div className="flex items-center justify-between">
272
+ <h3 className="text-sm font-semibold text-orange-400">
273
+ Flagged Episodes
274
+ <span className="text-xs text-slate-500 ml-2 font-normal">({count})</span>
275
+ </h3>
276
+ <div className="flex items-center gap-2">
277
+ <button onClick={handleCopy}
278
+ className="text-xs text-slate-400 hover:text-slate-200 transition-colors flex items-center gap-1"
279
+ title="Copy IDs">
280
+ {copied ? (
281
+ <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>
282
+ ) : (
283
+ <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>
284
+ )}
285
+ Copy
286
+ </button>
287
+ <button onClick={clear} className="text-xs text-slate-500 hover:text-red-400 transition-colors">Clear</button>
288
+ </div>
289
+ </div>
290
+ <p className="text-xs text-slate-300 tabular-nums leading-relaxed max-h-20 overflow-y-auto">{idStr}</p>
291
+ {onViewEpisodes && (
292
+ <button onClick={onViewEpisodes}
293
+ 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">
294
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
295
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
296
+ <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" />
297
+ </svg>
298
+ View flagged episodes
299
+ </button>
300
+ )}
301
+ <div className="bg-slate-900/60 rounded-md px-3 py-2 border border-slate-700/60 space-y-2.5">
302
+ <p className="text-xs text-slate-400">
303
+ <a href="https://github.com/huggingface/lerobot" target="_blank" rel="noopener noreferrer" className="text-orange-400 underline">LeRobot CLI</a> — delete flagged episodes:
304
+ </p>
305
+ <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>
306
+ <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>
307
+ </div>
308
+ </div>
309
+ );
310
+ }
311
+
312
+ const FilteringPanel: React.FC<FilteringPanelProps> = ({
313
+ repoId,
314
+ crossEpisodeData,
315
+ crossEpisodeLoading,
316
+ episodeLengthStats,
317
+ flatChartData,
318
+ fps,
319
+ onViewFlaggedEpisodes,
320
+ }) => {
321
+ return (
322
+ <div className="max-w-5xl mx-auto py-6 space-y-8">
323
+ <div>
324
+ <h2 className="text-xl font-bold text-slate-100">Filtering</h2>
325
+ <p className="text-sm text-slate-400 mt-1">
326
+ Identify and flag problematic episodes for removal. Flagged episodes appear in the sidebar and can be exported as a CLI command.
327
+ </p>
328
+ </div>
329
+
330
+ <FlaggedIdsCopyBar repoId={repoId} onViewEpisodes={onViewFlaggedEpisodes} />
331
+
332
+ {crossEpisodeData && <AutoPruneSection data={crossEpisodeData} />}
333
+
334
+ {episodeLengthStats?.allEpisodeLengths && (
335
+ <EpisodeLengthFilter episodes={episodeLengthStats.allEpisodeLengths} />
336
+ )}
337
+
338
+ {crossEpisodeLoading && (
339
+ <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
340
+ <div className="flex items-center gap-2 text-slate-400 text-sm py-4 justify-center">
341
+ <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
342
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
343
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
344
+ </svg>
345
+ Loading cross-episode data…
346
+ </div>
347
+ </div>
348
+ )}
349
+
350
+ {crossEpisodeData?.lowMovementEpisodes && (
351
+ <LowMovementSection episodes={crossEpisodeData.lowMovementEpisodes} />
352
+ )}
353
+
354
+ {crossEpisodeData?.trajectoryClustering && (
355
+ <FullscreenWrapper>
356
+ <TrajectoryClusteringSection data={crossEpisodeData.trajectoryClustering} numEpisodes={crossEpisodeData.numEpisodes} />
357
+ </FullscreenWrapper>
358
+ )}
359
+
360
+ <FullscreenWrapper>
361
+ <ActionVelocitySection
362
+ data={flatChartData}
363
+ agg={crossEpisodeData?.aggVelocity}
364
+ numEpisodes={crossEpisodeData?.numEpisodes}
365
+ jerkyEpisodes={crossEpisodeData?.jerkyEpisodes}
366
+ />
367
+ </FullscreenWrapper>
368
+ </div>
369
+ );
370
+ };
371
+
372
+ export default FilteringPanel;
373
+
src/components/overview-panel.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import React, { useState, useEffect, useRef, useCallback } from "react";
4
  import type { EpisodeFrameInfo, EpisodeFramesData } from "@/app/[org]/[dataset]/[episode]/fetch-data";
 
5
 
6
  const PAGE_SIZE = 48;
7
 
@@ -41,9 +42,12 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
41
  }
42
  }, [inView, showLast, info]);
43
 
 
 
 
44
  return (
45
  <div ref={containerRef} className="flex flex-col items-center">
46
- <div className="w-full aspect-video bg-slate-800 rounded overflow-hidden">
47
  {inView ? (
48
  <video
49
  ref={videoRef}
@@ -55,8 +59,22 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
55
  ) : (
56
  <div className="w-full h-full animate-pulse bg-slate-700" />
57
  )}
 
 
 
 
 
 
 
 
 
 
 
 
58
  </div>
59
- <p className="text-xs text-slate-400 mt-1 tabular-nums">ep {info.episodeIndex}</p>
 
 
60
  </div>
61
  );
62
  }
@@ -64,9 +82,12 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
64
  interface OverviewPanelProps {
65
  data: EpisodeFramesData | null;
66
  loading: boolean;
 
 
67
  }
68
 
69
- export default function OverviewPanel({ data, loading }: OverviewPanelProps) {
 
70
  const [selectedCamera, setSelectedCamera] = useState<string>("");
71
  const [showLast, setShowLast] = useState(false);
72
  const [page, setPage] = useState(0);
@@ -95,10 +116,18 @@ export default function OverviewPanel({ data, loading }: OverviewPanelProps) {
95
  );
96
  }
97
 
98
- const frames = data.framesByCamera[selectedCamera] ?? [];
 
99
 
100
  if (frames.length === 0) {
101
- return <p className="text-slate-500 italic py-8 text-center">No episode frames available.</p>;
 
 
 
 
 
 
 
102
  }
103
 
104
  const totalPages = Math.ceil(frames.length / PAGE_SIZE);
@@ -107,7 +136,7 @@ export default function OverviewPanel({ data, loading }: OverviewPanelProps) {
107
  return (
108
  <div className="max-w-7xl mx-auto py-6 space-y-5">
109
  <p className="text-sm text-slate-500">
110
- Use first/last frame views to spot episodes with bad end states, or other anomalies and to visualize diversity across the dataset.
111
  </p>
112
 
113
  {/* Controls row */}
@@ -126,6 +155,24 @@ export default function OverviewPanel({ data, loading }: OverviewPanelProps) {
126
  </select>
127
  )}
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  {/* First / Last toggle */}
130
  <div className="flex items-center gap-3">
131
  <span className={`text-sm ${!showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}>
 
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
 
 
42
  }
43
  }, [inView, showLast, info]);
44
 
45
+ const { has, toggle } = useFlaggedEpisodes();
46
+ const isFlagged = has(info.episodeIndex);
47
+
48
  return (
49
  <div ref={containerRef} className="flex flex-col items-center">
50
+ <div className="w-full aspect-video bg-slate-800 rounded overflow-hidden relative group">
51
  {inView ? (
52
  <video
53
  ref={videoRef}
 
59
  ) : (
60
  <div className="w-full h-full animate-pulse bg-slate-700" />
61
  )}
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
  );
80
  }
 
82
  interface OverviewPanelProps {
83
  data: EpisodeFramesData | null;
84
  loading: boolean;
85
+ flaggedOnly?: boolean;
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);
93
  const [page, setPage] = useState(0);
 
116
  );
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
+ );
131
  }
132
 
133
  const totalPages = Math.ceil(frames.length / PAGE_SIZE);
 
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 */}
 
155
  </select>
156
  )}
157
 
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>
174
+ )}
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"}`}>
src/components/side-nav.tsx CHANGED
@@ -1,7 +1,8 @@
1
  "use client";
2
 
3
  import Link from "next/link";
4
- import React from "react";
 
5
 
6
  import type { DatasetDisplayInfo } from "@/app/[org]/[dataset]/[episode]/fetch-data";
7
 
@@ -13,6 +14,8 @@ interface SidebarProps {
13
  currentPage: number;
14
  prevPage: () => void;
15
  nextPage: () => void;
 
 
16
  }
17
 
18
  const Sidebar: React.FC<SidebarProps> = ({
@@ -23,44 +26,73 @@ const Sidebar: React.FC<SidebarProps> = ({
23
  currentPage,
24
  prevPage,
25
  nextPage,
 
 
26
  }) => {
27
- // On mobile, allow toggling; on desktop the sidebar is always visible
28
- const [mobileVisible, setMobileVisible] = React.useState(false);
 
 
 
 
 
29
 
30
  return (
31
  <div className="flex z-10 shrink-0">
32
- {/* Sidebar panel — always visible on md+, togglable on mobile */}
33
  <nav
34
  className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words w-60 ${
35
  mobileVisible ? "block" : "hidden"
36
  } md:block`}
37
  aria-label="Sidebar navigation"
38
  >
39
- {/* Basic dataset info */}
40
  <ul className="text-sm text-slate-300 space-y-0.5">
41
  <li>Frames: {datasetInfo.total_frames.toLocaleString()}</li>
42
  <li>Episodes: {datasetInfo.total_episodes.toLocaleString()}</li>
43
  <li>FPS: {datasetInfo.fps}</li>
44
  </ul>
45
 
46
- <p className="mt-4 text-sm font-semibold text-slate-200">Episodes:</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- {/* Episodes list */}
49
  <div className="ml-2 mt-1">
50
  <ul>
51
- {paginatedEpisodes.map((episode) => (
52
- <li key={episode} className="mt-0.5 font-mono text-sm">
53
  <Link
54
  href={`./episode_${episode}`}
55
  className={`underline ${episode === episodeId ? "-ml-1 font-bold" : ""}`}
56
  >
57
  Episode {episode}
58
  </Link>
 
 
 
 
 
 
 
 
 
 
 
59
  </li>
60
  ))}
61
  </ul>
62
 
63
- {totalPages > 1 && (
64
  <div className="mt-3 flex items-center text-xs">
65
  <button
66
  onClick={prevPage}
@@ -90,7 +122,6 @@ const Sidebar: React.FC<SidebarProps> = ({
90
  </div>
91
  </nav>
92
 
93
- {/* Mobile toggle button */}
94
  <button
95
  className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0 md:hidden"
96
  onClick={() => setMobileVisible((prev) => !prev)}
 
1
  "use client";
2
 
3
  import Link from "next/link";
4
+ import React, { useMemo, useState } from "react";
5
+ import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
6
 
7
  import type { DatasetDisplayInfo } from "@/app/[org]/[dataset]/[episode]/fetch-data";
8
 
 
14
  currentPage: number;
15
  prevPage: () => void;
16
  nextPage: () => void;
17
+ showFlaggedOnly: boolean;
18
+ onShowFlaggedOnlyChange: (v: boolean) => void;
19
  }
20
 
21
  const Sidebar: React.FC<SidebarProps> = ({
 
26
  currentPage,
27
  prevPage,
28
  nextPage,
29
+ showFlaggedOnly,
30
+ onShowFlaggedOnlyChange,
31
  }) => {
32
+ const [mobileVisible, setMobileVisible] = useState(false);
33
+ const { flagged, count, toggle } = useFlaggedEpisodes();
34
+
35
+ const displayEpisodes = useMemo(() => {
36
+ if (!showFlaggedOnly || count === 0) return paginatedEpisodes;
37
+ return [...flagged].sort((a, b) => a - b);
38
+ }, [paginatedEpisodes, showFlaggedOnly, flagged, count]);
39
 
40
  return (
41
  <div className="flex z-10 shrink-0">
 
42
  <nav
43
  className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words w-60 ${
44
  mobileVisible ? "block" : "hidden"
45
  } md:block`}
46
  aria-label="Sidebar navigation"
47
  >
 
48
  <ul className="text-sm text-slate-300 space-y-0.5">
49
  <li>Frames: {datasetInfo.total_frames.toLocaleString()}</li>
50
  <li>Episodes: {datasetInfo.total_episodes.toLocaleString()}</li>
51
  <li>FPS: {datasetInfo.fps}</li>
52
  </ul>
53
 
54
+ <div className="mt-4 flex items-center justify-between">
55
+ <p className="text-sm font-semibold text-slate-200">Episodes:</p>
56
+ {count > 0 && (
57
+ <button
58
+ onClick={() => onShowFlaggedOnlyChange(!showFlaggedOnly)}
59
+ className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
60
+ showFlaggedOnly
61
+ ? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
62
+ : "text-slate-500 hover:text-slate-300 border border-slate-700"
63
+ }`}
64
+ >
65
+ Flagged ({count})
66
+ </button>
67
+ )}
68
+ </div>
69
 
 
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" : ""}`}
77
  >
78
  Episode {episode}
79
  </Link>
80
+ <button
81
+ onClick={() => toggle(episode)}
82
+ className={`text-xs leading-none px-0.5 rounded transition-colors ${
83
+ flagged.has(episode)
84
+ ? "text-orange-400 hover:text-orange-300"
85
+ : "text-slate-600 hover:text-slate-400"
86
+ }`}
87
+ title={flagged.has(episode) ? "Unflag" : "Flag"}
88
+ >
89
+
90
+ </button>
91
  </li>
92
  ))}
93
  </ul>
94
 
95
+ {!showFlaggedOnly && totalPages > 1 && (
96
  <div className="mt-3 flex items-center text-xs">
97
  <button
98
  onClick={prevPage}
 
122
  </div>
123
  </nav>
124
 
 
125
  <button
126
  className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0 md:hidden"
127
  onClick={() => setMobileVisible((prev) => !prev)}
src/components/stats-panel.tsx CHANGED
@@ -1,11 +1,10 @@
1
  "use client";
2
 
3
- import React, { useState, useMemo, useCallback } from "react";
4
  import type {
5
  DatasetDisplayInfo,
6
  ColumnMinMax,
7
  EpisodeLengthStats,
8
- EpisodeLengthInfo,
9
  CameraInfo,
10
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
11
 
@@ -82,108 +81,6 @@ const Card: React.FC<{ label: string; value: string | number }> = ({ label, valu
82
  </div>
83
  );
84
 
85
- function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
86
- const globalMin = useMemo(() => Math.min(...episodes.map((e) => e.lengthSeconds)), [episodes]);
87
- const globalMax = useMemo(() => Math.max(...episodes.map((e) => e.lengthSeconds)), [episodes]);
88
-
89
- const [rangeMin, setRangeMin] = useState(globalMin);
90
- const [rangeMax, setRangeMax] = useState(globalMax);
91
- const [showOutside, setShowOutside] = useState(false);
92
- const [copied, setCopied] = useState(false);
93
-
94
- const filtered = useMemo(() => {
95
- const inRange = episodes.filter((e) => e.lengthSeconds >= rangeMin && e.lengthSeconds <= rangeMax);
96
- const outRange = episodes.filter((e) => e.lengthSeconds < rangeMin || e.lengthSeconds > rangeMax);
97
- return showOutside ? outRange : inRange;
98
- }, [episodes, rangeMin, rangeMax, showOutside]);
99
-
100
- const ids = useMemo(() => filtered.map((e) => e.episodeIndex).sort((a, b) => a - b), [filtered]);
101
-
102
- const handleCopy = useCallback(() => {
103
- navigator.clipboard.writeText(ids.join(", "));
104
- setCopied(true);
105
- setTimeout(() => setCopied(false), 1500);
106
- }, [ids]);
107
-
108
- const step = Math.max(0.01, Math.round((globalMax - globalMin) * 0.001 * 100) / 100) || 0.01;
109
-
110
- return (
111
- <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
112
- <h3 className="text-sm font-semibold text-slate-200">Episode Length Filter</h3>
113
-
114
- {/* Range slider row */}
115
- <div className="space-y-2">
116
- <div className="flex items-center justify-between text-xs text-slate-400">
117
- <span className="tabular-nums">{rangeMin.toFixed(1)}s</span>
118
- <span className="tabular-nums">{rangeMax.toFixed(1)}s</span>
119
- </div>
120
- <div className="relative h-5">
121
- {/* track background */}
122
- <div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
123
- {/* active range highlight */}
124
- <div
125
- className="absolute top-1/2 -translate-y-1/2 h-1 rounded bg-orange-500"
126
- style={{
127
- left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
128
- right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
129
- }}
130
- />
131
- <input
132
- type="range"
133
- min={globalMin}
134
- max={globalMax}
135
- step={step}
136
- value={rangeMin}
137
- onChange={(e) => setRangeMin(Math.min(Number(e.target.value), rangeMax))}
138
- className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-orange-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-orange-500 [&::-moz-range-thumb]:cursor-pointer"
139
- />
140
- <input
141
- type="range"
142
- min={globalMin}
143
- max={globalMax}
144
- step={step}
145
- value={rangeMax}
146
- onChange={(e) => setRangeMax(Math.max(Number(e.target.value), rangeMin))}
147
- className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-orange-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-orange-500 [&::-moz-range-thumb]:cursor-pointer"
148
- />
149
- </div>
150
- </div>
151
-
152
- {/* Mode selector */}
153
- <div className="flex items-center gap-3">
154
- <span className="text-xs text-slate-400">Show:</span>
155
- <select
156
- value={showOutside ? "outside" : "inside"}
157
- onChange={(e) => setShowOutside(e.target.value === "outside")}
158
- className="bg-slate-900 text-slate-200 text-xs rounded px-2 py-1 border border-slate-600 focus:outline-none focus:border-orange-500"
159
- >
160
- <option value="inside">Episodes in range</option>
161
- <option value="outside">Episodes outside range</option>
162
- </select>
163
- <span className="text-xs text-slate-500 tabular-nums ml-auto">{ids.length} episode{ids.length !== 1 ? "s" : ""}</span>
164
- </div>
165
-
166
- {/* Results box */}
167
- <div className="relative bg-slate-900/70 rounded-md border border-slate-700 p-3 max-h-40 overflow-y-auto">
168
- <button
169
- onClick={handleCopy}
170
- className="sticky top-0 float-right ml-2 p-1.5 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-400 hover:text-slate-200 transition-colors backdrop-blur-sm"
171
- title="Copy to clipboard"
172
- >
173
- {copied ? (
174
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-400"><polyline points="20 6 9 17 4 12" /></svg>
175
- ) : (
176
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
177
- )}
178
- </button>
179
- <p className="text-sm text-slate-300 tabular-nums leading-relaxed">
180
- {ids.length > 0 ? ids.join(", ") : <span className="text-slate-500 italic">No episodes match</span>}
181
- </p>
182
- </div>
183
- </div>
184
- );
185
- }
186
-
187
  const StatsPanel: React.FC<StatsPanelProps> = ({
188
  datasetInfo,
189
  episodeId,
@@ -251,36 +148,6 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
251
  <Card label="Median" value={`${els.medianEpisodeLength}s`} />
252
  <Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
253
  </div>
254
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
255
- <div>
256
- <p className="text-xs text-slate-400 uppercase tracking-wide mb-2">Top 5 Shortest</p>
257
- <table className="w-full text-sm">
258
- <tbody>
259
- {els.shortestEpisodes.map((ep) => (
260
- <tr key={ep.episodeIndex} className="border-b border-slate-800/60">
261
- <td className="py-1 text-slate-300">ep {ep.episodeIndex}</td>
262
- <td className="py-1 text-right tabular-nums font-semibold">{ep.lengthSeconds}s</td>
263
- <td className="py-1 text-right tabular-nums text-slate-500 text-xs">{ep.frames} fr</td>
264
- </tr>
265
- ))}
266
- </tbody>
267
- </table>
268
- </div>
269
- <div>
270
- <p className="text-xs text-slate-400 uppercase tracking-wide mb-2">Top 5 Longest</p>
271
- <table className="w-full text-sm">
272
- <tbody>
273
- {els.longestEpisodes.map((ep) => (
274
- <tr key={ep.episodeIndex} className="border-b border-slate-800/60">
275
- <td className="py-1 text-slate-300">ep {ep.episodeIndex}</td>
276
- <td className="py-1 text-right tabular-nums font-semibold">{ep.lengthSeconds}s</td>
277
- <td className="py-1 text-right tabular-nums text-slate-500 text-xs">{ep.frames} fr</td>
278
- </tr>
279
- ))}
280
- </tbody>
281
- </table>
282
- </div>
283
- </div>
284
  </div>
285
 
286
  {els.episodeLengthHistogram.length > 0 && (
@@ -295,39 +162,8 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
295
  </div>
296
  )}
297
 
298
- <EpisodeLengthFilter episodes={els.allEpisodeLengths} />
299
  </>
300
  )}
301
-
302
- {/* Column min/max table */}
303
- {columnMinMax && columnMinMax.length > 0 && (
304
- <div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
305
- <h3 className="text-sm font-semibold text-slate-200 mb-4">
306
- Column Min / Max
307
- <span className="text-xs text-slate-500 ml-2 font-normal">(episode {episodeId})</span>
308
- </h3>
309
- <div className="overflow-x-auto max-h-96 overflow-y-auto">
310
- <table className="w-full text-sm">
311
- <thead>
312
- <tr className="text-slate-400 border-b border-slate-700 sticky top-0 bg-slate-800">
313
- <th className="text-left py-2 pr-4 font-medium">Column</th>
314
- <th className="text-right py-2 px-4 font-medium">Min</th>
315
- <th className="text-right py-2 pl-4 font-medium">Max</th>
316
- </tr>
317
- </thead>
318
- <tbody>
319
- {columnMinMax.map((col) => (
320
- <tr key={col.column} className="border-b border-slate-800/60 hover:bg-slate-700/20">
321
- <td className="py-1.5 pr-4 text-slate-300 truncate max-w-xs" title={col.column}>{col.column}</td>
322
- <td className="py-1.5 px-4 text-right tabular-nums text-slate-300">{col.min}</td>
323
- <td className="py-1.5 pl-4 text-right tabular-nums text-slate-300">{col.max}</td>
324
- </tr>
325
- ))}
326
- </tbody>
327
- </table>
328
- </div>
329
- </div>
330
- )}
331
  </div>
332
  );
333
  };
 
1
  "use client";
2
 
3
+ import React, { useState } from "react";
4
  import type {
5
  DatasetDisplayInfo,
6
  ColumnMinMax,
7
  EpisodeLengthStats,
 
8
  CameraInfo,
9
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
10
 
 
81
  </div>
82
  );
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  const StatsPanel: React.FC<StatsPanelProps> = ({
85
  datasetInfo,
86
  episodeId,
 
148
  <Card label="Median" value={`${els.medianEpisodeLength}s`} />
149
  <Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
150
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  </div>
152
 
153
  {els.episodeLengthHistogram.length > 0 && (
 
162
  </div>
163
  )}
164
 
 
165
  </>
166
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  </div>
168
  );
169
  };
src/context/flagged-episodes-context.tsx ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from "react";
4
+
5
+ const STORAGE_KEY = "flagged-episodes";
6
+
7
+ function loadFromStorage(): Set<number> {
8
+ if (typeof window === "undefined") return new Set();
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 = {
21
+ flagged: Set<number>;
22
+ count: number;
23
+ has: (id: number) => boolean;
24
+ toggle: (id: number) => void;
25
+ addMany: (ids: number[]) => void;
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;
55
+ });
56
+ }, []);
57
+
58
+ const clear = useCallback(() => setFlagged(new Set()), []);
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}>
68
+ {children}
69
+ </FlaggedEpisodesContext.Provider>
70
+ );
71
+ };