skapoor-wpi commited on
Commit
3c0f98c
·
1 Parent(s): 2ede05f

AI-in-the-loop

Browse files
.env.example CHANGED
@@ -1 +1,4 @@
1
  DANGEROUSLY_DISABLE_HOST_CHECK=true
 
 
 
 
1
  DANGEROUSLY_DISABLE_HOST_CHECK=true
2
+ HF_API_TOKEN=your-hf-token-here
3
+ AI_MODEL=Qwen/Qwen2.5-72B-Instruct
4
+ USE_RAG=false
.gitignore CHANGED
@@ -35,3 +35,7 @@ yarn-error.log*
35
  pnpm-debug.log*
36
 
37
  backend/package-lock.json
 
 
 
 
 
35
  pnpm-debug.log*
36
 
37
  backend/package-lock.json
38
+
39
+ # Cached embeddings
40
+ backend/.description_embeddings.npy
41
+ .env.example
Dockerfile CHANGED
@@ -20,7 +20,8 @@ FROM python:3.11-slim
20
  WORKDIR /app
21
 
22
  COPY backend/requirements.txt backend/requirements.txt
23
- RUN pip install --no-cache-dir -r backend/requirements.txt
 
24
 
25
  COPY --from=builder /build/frontend/build frontend/build
26
  COPY backend/ backend/
 
20
  WORKDIR /app
21
 
22
  COPY backend/requirements.txt backend/requirements.txt
23
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \
24
+ pip install --no-cache-dir -r backend/requirements.txt
25
 
26
  COPY --from=builder /build/frontend/build frontend/build
27
  COPY backend/ backend/
backend/app.py CHANGED
@@ -1,18 +1,43 @@
1
  #!/usr/bin/env python3
2
- from flask import Flask, jsonify, request
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from flask_cors import CORS
4
  import pandas as pd
5
  import numpy as np
6
  from sklearn.feature_extraction.text import TfidfVectorizer
7
  from sklearn.cluster import KMeans
8
  import umap
9
-
10
- import os
 
11
 
12
  app = Flask(__name__, static_folder='../frontend/build', static_url_path='')
13
  CORS(app)
 
 
 
 
 
14
  BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
  CSV_FILE = os.path.join(BASE_DIR, 'datasets', 'csv-gp-combined.csv')
 
16
 
17
  DEFAULT_WEIGHTS = {
18
  'Planning Method': 10,
@@ -27,17 +52,227 @@ DEFAULT_WEIGHTS = {
27
  'Metric(s) Used ': 5,
28
  'Camera Position(s)': 4,
29
  'Language': 4,
 
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
32
  UMAP_N_NEIGHBORS = 15
33
  UMAP_MIN_DIST = 0.1
34
  UMAP_METRIC = 'cosine'
35
  UMAP_RANDOM_STATE = 42
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def compute_weighted_embeddings(df, weights):
38
- """Compute TF-IDF embeddings for each column and apply weights."""
 
39
  feature_matrices = []
40
-
41
  for col, weight in weights.items():
42
  if weight == 0:
43
  print(f"Skipping '{col}' (weight=0)")
@@ -45,98 +280,206 @@ def compute_weighted_embeddings(df, weights):
45
  if col not in df.columns:
46
  print(f"Warning: Column '{col}' not found")
47
  continue
48
-
49
- texts = df[col].fillna('').astype(str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  vectorizer = TfidfVectorizer(max_features=50, ngram_range=(1, 2))
51
-
52
  try:
53
  embeddings = vectorizer.fit_transform(texts).toarray()
54
  weighted_embeddings = embeddings * np.sqrt(weight)
55
  feature_matrices.append(weighted_embeddings)
56
- print(f"Processed '{col}': {embeddings.shape}, weight={weight}")
57
  except Exception as e:
58
  print(f"Skipping '{col}': {e}")
59
-
60
  if not feature_matrices:
61
  raise ValueError("No valid columns to process. At least one column must have weight > 0")
62
-
63
  combined = np.hstack(feature_matrices)
64
  print(f"Combined feature matrix: {combined.shape}")
65
  return combined
66
 
67
  def compute_umap(features):
68
- """Compute UMAP projection."""
 
 
 
 
69
  reducer = umap.UMAP(
70
  n_neighbors=UMAP_N_NEIGHBORS,
71
  min_dist=UMAP_MIN_DIST,
72
- metric=UMAP_METRIC,
73
  random_state=UMAP_RANDOM_STATE,
74
- n_components=2
 
75
  )
76
- return reducer.fit_transform(features)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  @app.route('/api/umap', methods=['GET', 'POST'])
79
  def get_umap():
80
  """Compute and return UMAP projection with metadata."""
81
  try:
82
- # Get custom weights from POST body, or use defaults
83
  if request.method == 'POST':
84
  data = request.get_json() or {}
85
  weights = data.get('weights', DEFAULT_WEIGHTS)
 
86
  print(f"Using custom weights: {weights}")
 
 
87
  else:
88
  weights = DEFAULT_WEIGHTS
89
  print(f"Using default weights")
90
-
91
- print(f"Loading CSV: {CSV_FILE}")
92
- df = pd.read_csv(CSV_FILE)
93
- print(f"Loaded {len(df)} rows")
94
-
95
- features = compute_weighted_embeddings(df, weights)
96
- print("Computing UMAP...")
97
- embedding = compute_umap(features)
98
- print("UMAP complete!")
99
-
100
- # K-Means clustering
101
- N_CLUSTERS = 11
102
- km = KMeans(n_clusters=N_CLUSTERS, random_state=42, n_init=10)
103
- cluster_labels = km.fit_predict(features).tolist()
104
- print(f"K-Means: {N_CLUSTERS} clusters")
105
 
106
- # Compute value dominant cluster mapping for stoplight grid
107
- weighted_cols = [col for col, w in weights.items() if w > 0 and col in df.columns]
108
- value_cluster_map = {}
109
- for col in weighted_cols:
110
- value_cluster_map[col] = {}
111
- vals = df[col].fillna('').astype(str).str.split(',').str[0].str.strip()
112
- for val in vals.unique():
113
- if val == '':
114
- continue
115
- mask = vals == val
116
- cluster_counts = pd.Series(cluster_labels)[mask].value_counts()
117
- value_cluster_map[col][val] = int(cluster_counts.index[0])
118
-
119
- response_data = []
120
- for i, row in df.iterrows():
121
- # Replace NaN values with empty strings
122
- metadata = {}
123
- for col in df.columns:
124
- val = row.get(col, '')
125
- if pd.isna(val):
126
- metadata[col] = ''
127
- else:
128
- metadata[col] = str(val)
129
-
130
- point = {
131
- 'id': i,
132
- 'name': row.get('Name', ''),
133
- 'x': float(embedding[i, 0]),
134
- 'y': float(embedding[i, 1]),
135
- 'description': row.get('Description', ''),
136
- 'cluster': cluster_labels[i],
137
- 'metadata': metadata
138
- }
139
- response_data.append(point)
140
 
141
  return jsonify({
142
  'success': True,
@@ -144,22 +487,418 @@ def get_umap():
144
  'config': {
145
  'weights': weights,
146
  'defaultWeights': DEFAULT_WEIGHTS,
 
147
  'n_neighbors': UMAP_N_NEIGHBORS,
148
  'min_dist': UMAP_MIN_DIST,
149
  'metric': UMAP_METRIC
150
  },
151
  'clustering': {
152
- 'n_clusters': N_CLUSTERS,
153
- 'value_cluster_map': value_cluster_map
 
 
 
 
 
154
  }
155
  })
156
-
 
 
157
  except Exception as e:
158
  print(f"Error: {e}")
159
  import traceback
160
  traceback.print_exc()
161
  return jsonify({'success': False, 'error': str(e)}), 500
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  @app.route('/api/health')
164
  def health():
165
  """Health check endpoint."""
@@ -177,7 +916,7 @@ def not_found(e):
177
 
178
  if __name__ == '__main__':
179
  port = int(os.environ.get('PORT', 5005))
180
- debug = os.environ.get('RENDER') is None # debug mode only locally
181
  print(f"Starting Flask server on http://0.0.0.0:{port}")
182
  print(f"CSV file: {CSV_FILE}")
183
  print(f"Default weights: {DEFAULT_WEIGHTS}")
 
1
  #!/usr/bin/env python3
2
+ import os, signal, sys
3
+
4
+ # Load .env file from repo root
5
+ from dotenv import load_dotenv
6
+ load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
7
+
8
+ os.environ['OMP_NUM_THREADS'] = '1'
9
+ os.environ['TOKENIZERS_PARALLELISM'] = 'false'
10
+ os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
11
+ os.environ['HF_HUB_DISABLE_TELEMETRY'] = '1'
12
+
13
+ # Catch crashes
14
+ def crash_handler(signum, frame):
15
+ print(f"\n!!! PROCESS KILLED BY SIGNAL {signum} !!!", file=sys.stderr, flush=True)
16
+ sys.exit(1)
17
+ signal.signal(signal.SIGABRT, crash_handler)
18
+
19
+ import json
20
+ from flask import Flask, jsonify, request, send_from_directory
21
  from flask_cors import CORS
22
  import pandas as pd
23
  import numpy as np
24
  from sklearn.feature_extraction.text import TfidfVectorizer
25
  from sklearn.cluster import KMeans
26
  import umap
27
+ from sentence_transformers import SentenceTransformer
28
+ import csv
29
+ import io
30
 
31
  app = Flask(__name__, static_folder='../frontend/build', static_url_path='')
32
  CORS(app)
33
+
34
+ # Load sentence-transformer model once at startup
35
+ print("Loading sentence-transformer model...")
36
+ st_model = SentenceTransformer('all-MiniLM-L6-v2')
37
+ print("Model loaded.")
38
  BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
39
  CSV_FILE = os.path.join(BASE_DIR, 'datasets', 'csv-gp-combined.csv')
40
+ EMBEDDINGS_CACHE = os.path.join(BASE_DIR, 'backend', '.description_embeddings.npy')
41
 
42
  DEFAULT_WEIGHTS = {
43
  'Planning Method': 10,
 
52
  'Metric(s) Used ': 5,
53
  'Camera Position(s)': 4,
54
  'Language': 4,
55
+ 'Description': 7,
56
  }
57
 
58
+ DERIVED_COLUMNS = [
59
+ 'Grasp Dimensionality',
60
+ 'Learning Paradigm',
61
+ 'Sensor Complexity',
62
+ 'Scene Difficulty',
63
+ 'Gripper Type',
64
+ 'ML Framework',
65
+ 'Method Era',
66
+ ]
67
+
68
  UMAP_N_NEIGHBORS = 15
69
  UMAP_MIN_DIST = 0.1
70
  UMAP_METRIC = 'cosine'
71
  UMAP_RANDOM_STATE = 42
72
 
73
+ # Domain context for AI insight prompts
74
+ DOMAIN_CONTEXT = """ROBOTICS DOMAIN CONTEXT:
75
+ Grasp planning is the problem of computing how a robot should position its gripper to pick up objects.
76
+
77
+ Key distinctions that matter:
78
+ - Planning Method: "Sampling" generates many candidate grasps and scores them; "Direct regression" predicts a grasp pose end-to-end from sensor data; "Analytical" uses geometric/force analysis; "RL" learns through trial-and-error; "Generative" uses models like VAEs/diffusion to generate diverse grasps.
79
+ - Object Configuration: "Singulated" = one isolated object (easiest); "Structured" = orderly arrangement; "Packed" = objects touching but organized; "Cluttered" = random pile (hardest) — requires reasoning about occlusion and inter-object contact. "Piled" = heaped objects.
80
+ - End-effector: "Two-finger" (parallel-jaw) grippers are simple but limited; "Multi-finger" and "Three-finger" (dexterous) grippers can perform complex in-hand manipulation but are much harder to plan for; "Suction" grippers work well on flat surfaces.
81
+ - Input Data: "Point cloud" and "Depth image" provide 3D geometry; "RGB" adds appearance; "RGBD" combines both; "TSDF" is a volumetric 3D representation. More modalities = more information but more complexity.
82
+ - Output Pose: "6-DoF" (x,y,z + roll,pitch,yaw) is the standard full grasp pose; "7-DoF" adds gripper width or approach angle; "2D grasp rectangle" is a simpler top-down formulation.
83
+ - Training Data: "Sim" = trained in simulation (scalable but sim-to-real gap); "Real" = trained on real robot data (expensive but accurate); methods using both attempt to bridge the gap.
84
+ - Backbone: The neural network architecture — PointNet/PointNet++ process point clouds directly; ResNet/VGG process images; transformers (ViT) handle both.
85
+ - Camera Position: "Overhead" = top-down view; "Eye-in-hand" = camera on the robot gripper; "Multi-view" = multiple cameras for better 3D reconstruction.
86
+
87
+ Why clustering matters: Methods that cluster together share fundamental design choices. Separation between clusters often reflects genuinely different philosophies (e.g., learning-based vs. analytical, or 2D vs. 3D grasp representations)."""
88
+
89
+ # AI Copilot configuration
90
+ # Supports: "ollama" (local, no key needed) or "huggingface" (needs HF_API_TOKEN)
91
+ AI_PROVIDER = os.environ.get('AI_PROVIDER', 'ollama')
92
+ OLLAMA_BASE_URL = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434')
93
+ OLLAMA_MODEL = os.environ.get('OLLAMA_MODEL', 'llama3.1:8b')
94
+ HF_API_TOKEN = os.environ.get('HF_API_TOKEN', os.environ.get('HF_TOKEN', ''))
95
+ HF_MODEL = os.environ.get('AI_MODEL', 'Qwen/Qwen2.5-72B-Instruct')
96
+ USE_RAG = os.environ.get('USE_RAG', 'false').lower() == 'true'
97
+
98
+
99
+ def llm_chat(messages, max_tokens=2048, temperature=0.3):
100
+ """Send a chat completion request to the configured LLM provider."""
101
+ if AI_PROVIDER == 'ollama':
102
+ import urllib.request
103
+ payload = json.dumps({
104
+ 'model': OLLAMA_MODEL,
105
+ 'messages': messages,
106
+ 'stream': False,
107
+ 'options': {'temperature': temperature, 'num_predict': max_tokens}
108
+ }).encode('utf-8')
109
+ req = urllib.request.Request(
110
+ f'{OLLAMA_BASE_URL}/api/chat',
111
+ data=payload,
112
+ headers={'Content-Type': 'application/json'}
113
+ )
114
+ with urllib.request.urlopen(req, timeout=120) as resp:
115
+ result = json.loads(resp.read().decode('utf-8'))
116
+ return result['message']['content'].strip()
117
+ else:
118
+ # HuggingFace Inference API
119
+ if not HF_API_TOKEN:
120
+ raise ValueError('HF_API_TOKEN not configured. Set it as an environment variable.')
121
+ from huggingface_hub import InferenceClient
122
+ client = InferenceClient(token=HF_API_TOKEN)
123
+ completion = client.chat_completion(
124
+ model=HF_MODEL,
125
+ messages=messages,
126
+ max_tokens=max_tokens,
127
+ temperature=temperature,
128
+ )
129
+ return completion.choices[0].message.content.strip()
130
+
131
+ SHORT_COLUMN_NAMES = {
132
+ 'Planning Method': 'Plan',
133
+ 'Training Data': 'Train',
134
+ 'End-effector Hardware': 'Effector',
135
+ 'Object Configuration': 'ObjConfig',
136
+ 'Input Data': 'Input',
137
+ 'Output Pose': 'Output',
138
+ 'Corresponding Dataset (see repository linked above)': 'Dataset',
139
+ 'Simulator (see repository linked above)': 'Sim',
140
+ 'Backbone': 'Backbone',
141
+ 'Metric(s) Used ': 'Metrics',
142
+ 'Camera Position(s)': 'Camera',
143
+ 'Language': 'Lang',
144
+ 'Description': 'Desc',
145
+ }
146
+
147
+ def smart_split(value):
148
+ """Split a comma-separated string, respecting double-quoted fields.
149
+ E.g. 'Dexterous grasp, "6-DoF grasp pose (x, y, z, r, p, y)"'
150
+ -> ['Dexterous grasp', '6-DoF grasp pose (x, y, z, r, p, y)']
151
+ """
152
+ if not value or (isinstance(value, float) and np.isnan(value)):
153
+ return []
154
+ s = str(value).strip()
155
+ if not s:
156
+ return []
157
+ reader = csv.reader(io.StringIO(s), skipinitialspace=True)
158
+ parts = next(reader)
159
+ return [p.strip() for p in parts if p.strip()]
160
+
161
+ def normalize_multi_value(val):
162
+ """Sort multi-value cells alphabetically so order doesn't affect TF-IDF."""
163
+ parts = smart_split(val)
164
+ return ', '.join(sorted(parts)) if parts else ''
165
+
166
+ def compute_derived_features(df):
167
+ """Compute derived feature columns from existing data."""
168
+ n = len(df)
169
+ result = {col: [''] * n for col in DERIVED_COLUMNS}
170
+
171
+ for i in range(n):
172
+ # --- Grasp Dimensionality (from Output Pose) ---
173
+ output = str(df.at[i, 'Output Pose']) if pd.notna(df.at[i, 'Output Pose']) else ''
174
+ if '6-DoF' in output:
175
+ result['Grasp Dimensionality'][i] = '6-DoF'
176
+ elif '7-DoF' in output:
177
+ result['Grasp Dimensionality'][i] = '7-DoF'
178
+ elif '2D grasp' in output:
179
+ result['Grasp Dimensionality'][i] = '2D'
180
+ elif 'Grasp policy' in output:
181
+ result['Grasp Dimensionality'][i] = 'Policy'
182
+ elif 'Grasp success' in output:
183
+ result['Grasp Dimensionality'][i] = 'Evaluation'
184
+ else:
185
+ result['Grasp Dimensionality'][i] = 'Other'
186
+
187
+ # --- Learning Paradigm (from Planning Method + Training Data) ---
188
+ method = str(df.at[i, 'Planning Method']) if pd.notna(df.at[i, 'Planning Method']) else ''
189
+ training = str(df.at[i, 'Training Data']) if pd.notna(df.at[i, 'Training Data']) else ''
190
+ method_parts = [p.strip() for p in method.split(',')]
191
+
192
+ if training == 'Training-less':
193
+ result['Learning Paradigm'][i] = 'Classical'
194
+ elif all(p in ('Analytical', 'Sampling', 'Optimization') for p in method_parts):
195
+ result['Learning Paradigm'][i] = 'Classical'
196
+ elif any('Reinforcement' in p for p in method_parts):
197
+ result['Learning Paradigm'][i] = 'RL-based'
198
+ elif any(p in ('Direct regression', 'Generative') for p in method_parts) and len(method_parts) == 1:
199
+ result['Learning Paradigm'][i] = 'Learning-based'
200
+ elif 'Direct regression' in method_parts or 'Generative' in method_parts:
201
+ result['Learning Paradigm'][i] = 'Learning-based'
202
+ else:
203
+ result['Learning Paradigm'][i] = 'Hybrid'
204
+
205
+ # --- Sensor Complexity (from Input Data) ---
206
+ input_data = str(df.at[i, 'Input Data']) if pd.notna(df.at[i, 'Input Data']) else ''
207
+ input_parts = smart_split(input_data)
208
+ input_lower = input_data.lower()
209
+
210
+ if 'natural language' in input_lower or len(input_parts) > 1:
211
+ result['Sensor Complexity'][i] = 'Multimodal'
212
+ elif any(k in input_lower for k in ('point cloud', 'tsdf', '3d', 'mesh', 'voxel')):
213
+ result['Sensor Complexity'][i] = '3D'
214
+ elif 'rgbd' in input_lower:
215
+ result['Sensor Complexity'][i] = '2.5D'
216
+ elif any(k in input_lower for k in ('rgb', 'depth')):
217
+ result['Sensor Complexity'][i] = '2D'
218
+ else:
219
+ result['Sensor Complexity'][i] = 'Other'
220
+
221
+ # --- Scene Difficulty (from Object Configuration) ---
222
+ obj_config = str(df.at[i, 'Object Configuration']) if pd.notna(df.at[i, 'Object Configuration']) else ''
223
+ difficulty_map = {'Singulated': 1, 'Structured': 2, 'Cluttered': 3, 'Packed': 4, 'Piled': 5, 'Stacked': 5}
224
+ label_map = {1: 'Singulated', 2: 'Structured', 3: 'Cluttered', 4: 'Packed', 5: 'Piled'}
225
+ parts = smart_split(obj_config)
226
+ max_diff = 0
227
+ for p in parts:
228
+ max_diff = max(max_diff, difficulty_map.get(p, 0))
229
+ result['Scene Difficulty'][i] = label_map.get(max_diff, 'Unknown')
230
+
231
+ # --- Gripper Type (from End-effector Hardware) ---
232
+ hardware = str(df.at[i, 'End-effector Hardware']) if pd.notna(df.at[i, 'End-effector Hardware']) else ''
233
+ hw_parts = smart_split(hardware)
234
+ if len(hw_parts) > 1:
235
+ result['Gripper Type'][i] = 'Multi-gripper'
236
+ elif any(k in hardware for k in ('Multi-finger', 'Three-finger')):
237
+ result['Gripper Type'][i] = 'Dexterous'
238
+ elif 'Suction' in hardware:
239
+ result['Gripper Type'][i] = 'Suction'
240
+ elif 'Two-finger' in hardware:
241
+ result['Gripper Type'][i] = 'Parallel-jaw'
242
+ else:
243
+ result['Gripper Type'][i] = 'Unknown'
244
+
245
+ # --- ML Framework (from Language) ---
246
+ lang = str(df.at[i, 'Language']) if pd.notna(df.at[i, 'Language']) else ''
247
+ if 'PyTorch' in lang:
248
+ result['ML Framework'][i] = 'PyTorch'
249
+ elif 'TensorFlow' in lang:
250
+ result['ML Framework'][i] = 'TensorFlow'
251
+ elif 'Keras' in lang:
252
+ result['ML Framework'][i] = 'Keras'
253
+ else:
254
+ result['ML Framework'][i] = 'None'
255
+
256
+ # --- Method Era (from Year) ---
257
+ year_val = df.at[i, 'Year (Initial Release)']
258
+ if pd.notna(year_val):
259
+ year = int(year_val)
260
+ if year <= 2018:
261
+ result['Method Era'][i] = 'Pioneer (2016-2018)'
262
+ elif year <= 2021:
263
+ result['Method Era'][i] = 'Growth (2019-2021)'
264
+ else:
265
+ result['Method Era'][i] = 'Modern (2022+)'
266
+ else:
267
+ result['Method Era'][i] = 'Unknown'
268
+
269
+ return result
270
+
271
  def compute_weighted_embeddings(df, weights):
272
+ """Compute TF-IDF embeddings for categorical columns and sentence-transformer
273
+ embeddings for Description, then combine with weights."""
274
  feature_matrices = []
275
+
276
  for col, weight in weights.items():
277
  if weight == 0:
278
  print(f"Skipping '{col}' (weight=0)")
 
280
  if col not in df.columns:
281
  print(f"Warning: Column '{col}' not found")
282
  continue
283
+
284
+ # Use sentence-transformer for Description column
285
+ # PCA to 50 dims to match TF-IDF scale and prevent dominating the feature matrix
286
+ if col == 'Description':
287
+ n_rows = len(df)
288
+ embeddings = None
289
+ # Use cache only when processing the full (unfiltered) dataset
290
+ if n_rows == 56 and os.path.exists(EMBEDDINGS_CACHE):
291
+ try:
292
+ cached = np.load(EMBEDDINGS_CACHE)
293
+ if cached.shape[0] == n_rows:
294
+ embeddings = cached
295
+ print(f"Loaded cached description embeddings: {embeddings.shape}")
296
+ else:
297
+ print(f"Cache shape mismatch ({cached.shape[0]} vs {n_rows}), recomputing...")
298
+ except Exception as e:
299
+ print(f"Cache load failed: {e}, recomputing...")
300
+ if embeddings is None:
301
+ texts = df[col].fillna('').astype(str).tolist()
302
+ full_embeddings = st_model.encode(texts, show_progress_bar=False)
303
+ from sklearn.decomposition import PCA
304
+ n_components = min(50, n_rows - 1) if n_rows > 1 else 1
305
+ pca = PCA(n_components=n_components, random_state=42)
306
+ embeddings = pca.fit_transform(full_embeddings)
307
+ # Only cache for full dataset
308
+ if n_rows == 56:
309
+ np.save(EMBEDDINGS_CACHE, embeddings)
310
+ print(f"Computed description embeddings: {full_embeddings.shape} -> {embeddings.shape}")
311
+ weighted_embeddings = embeddings * np.sqrt(weight)
312
+ feature_matrices.append(weighted_embeddings)
313
+ print(f"Processed '{col}' (sentence-transformer+PCA): {embeddings.shape}, weight={weight}")
314
+ continue
315
+
316
+ texts = df[col].fillna('').apply(normalize_multi_value)
317
  vectorizer = TfidfVectorizer(max_features=50, ngram_range=(1, 2))
318
+
319
  try:
320
  embeddings = vectorizer.fit_transform(texts).toarray()
321
  weighted_embeddings = embeddings * np.sqrt(weight)
322
  feature_matrices.append(weighted_embeddings)
323
+ print(f"Processed '{col}' (TF-IDF): {embeddings.shape}, weight={weight}")
324
  except Exception as e:
325
  print(f"Skipping '{col}': {e}")
326
+
327
  if not feature_matrices:
328
  raise ValueError("No valid columns to process. At least one column must have weight > 0")
329
+
330
  combined = np.hstack(feature_matrices)
331
  print(f"Combined feature matrix: {combined.shape}")
332
  return combined
333
 
334
  def compute_umap(features):
335
+ """Compute UMAP projection using precomputed distances to avoid
336
+ torch/UMAP OpenMP segfault on macOS."""
337
+ from sklearn.metrics import pairwise_distances
338
+ dist_matrix = pairwise_distances(features, metric=UMAP_METRIC)
339
+ print(f"Precomputed {UMAP_METRIC} distance matrix: {dist_matrix.shape}")
340
  reducer = umap.UMAP(
341
  n_neighbors=UMAP_N_NEIGHBORS,
342
  min_dist=UMAP_MIN_DIST,
343
+ metric='precomputed',
344
  random_state=UMAP_RANDOM_STATE,
345
+ n_components=2,
346
+ n_jobs=1
347
  )
348
+ return reducer.fit_transform(dist_matrix)
349
+
350
+ def run_umap_pipeline(weights, filter_methods=None):
351
+ """Core UMAP/clustering pipeline. Returns (response_data, clustering_info, df, df_full) or raises."""
352
+ import sys
353
+
354
+ print(f"Loading CSV: {CSV_FILE}")
355
+ df_full = pd.read_csv(CSV_FILE)
356
+ print(f"Loaded {len(df_full)} rows")
357
+
358
+ # Apply method filter if provided
359
+ if filter_methods:
360
+ df = df_full[df_full['Name'].isin(filter_methods)].reset_index(drop=True)
361
+ print(f"Filtered to {len(df)} methods")
362
+ if len(df) == 0:
363
+ raise ValueError('No methods matched the filter')
364
+ else:
365
+ df = df_full
366
+
367
+ # Compute derived features (for frontend UI, not embeddings)
368
+ derived = compute_derived_features(df)
369
+ print(f"Computed {len(DERIVED_COLUMNS)} derived features")
370
+
371
+ features = compute_weighted_embeddings(df, weights)
372
+ print("Computing UMAP...")
373
+ sys.stdout.flush()
374
+
375
+ # Adjust UMAP/clustering params for small filtered sets
376
+ n_methods = len(df)
377
+ n_neighbors = min(UMAP_N_NEIGHBORS, max(2, n_methods - 1))
378
+ n_clusters = min(11, max(2, n_methods // 2)) if n_methods >= 4 else max(1, n_methods)
379
+
380
+ try:
381
+ if n_methods == 1:
382
+ embedding = np.array([[0.0, 0.0]])
383
+ print("Single method — placed at origin")
384
+ elif n_methods < 4:
385
+ from sklearn.decomposition import PCA
386
+ n_pca = min(2, features.shape[1], n_methods)
387
+ pca = PCA(n_components=n_pca, random_state=42)
388
+ embedding = pca.fit_transform(features)
389
+ if embedding.shape[1] == 1:
390
+ embedding = np.hstack([embedding, np.zeros((n_methods, 1))])
391
+ print(f"Used PCA (only {n_methods} methods)")
392
+ else:
393
+ from sklearn.metrics import pairwise_distances
394
+ dist_matrix = pairwise_distances(features, metric=UMAP_METRIC)
395
+ reducer = umap.UMAP(
396
+ n_neighbors=n_neighbors,
397
+ min_dist=UMAP_MIN_DIST,
398
+ metric='precomputed',
399
+ random_state=UMAP_RANDOM_STATE,
400
+ n_components=2,
401
+ n_jobs=1
402
+ )
403
+ embedding = reducer.fit_transform(dist_matrix)
404
+ except Exception as umap_err:
405
+ print(f"UMAP CRASHED: {umap_err}")
406
+ import traceback
407
+ traceback.print_exc()
408
+ sys.stdout.flush()
409
+ raise
410
+ print("UMAP complete!")
411
+
412
+ # K-Means clustering
413
+ if n_methods <= 1:
414
+ cluster_labels = [0] * n_methods
415
+ else:
416
+ actual_clusters = min(n_clusters, n_methods)
417
+ km = KMeans(n_clusters=actual_clusters, random_state=42, n_init=10)
418
+ cluster_labels = km.fit_predict(features).tolist()
419
+ print(f"K-Means: {n_clusters} clusters ({n_methods} methods)")
420
+
421
+ # Compute value → dominant cluster mapping
422
+ weighted_cols = [col for col, w in weights.items() if w > 0 and col in df.columns and col != 'Description']
423
+ value_cluster_map = {}
424
+ for col in weighted_cols:
425
+ value_cluster_map[col] = {}
426
+ val_cluster_pairs = []
427
+ for idx, raw in enumerate(df[col].fillna('').astype(str)):
428
+ for part in smart_split(raw):
429
+ val_cluster_pairs.append((part, cluster_labels[idx]))
430
+ if not val_cluster_pairs:
431
+ continue
432
+ pairs_df = pd.DataFrame(val_cluster_pairs, columns=['value', 'cluster'])
433
+ for val, group in pairs_df.groupby('value'):
434
+ dominant = group['cluster'].value_counts().index[0]
435
+ value_cluster_map[col][val] = int(dominant)
436
+
437
+ # Build response data
438
+ response_data = []
439
+ for i, row in df.iterrows():
440
+ metadata = {}
441
+ for col in df.columns:
442
+ val = row.get(col, '')
443
+ metadata[col] = '' if pd.isna(val) else str(val)
444
+ for col in DERIVED_COLUMNS:
445
+ metadata[col] = derived[col][i]
446
+
447
+ response_data.append({
448
+ 'id': i,
449
+ 'name': row.get('Name', ''),
450
+ 'x': float(embedding[i, 0]),
451
+ 'y': float(embedding[i, 1]),
452
+ 'description': row.get('Description', ''),
453
+ 'cluster': cluster_labels[i],
454
+ 'metadata': metadata
455
+ })
456
+
457
+ clustering_info = {
458
+ 'n_clusters': n_clusters,
459
+ 'cluster_labels': cluster_labels,
460
+ 'value_cluster_map': value_cluster_map
461
+ }
462
+
463
+ return response_data, clustering_info, df, df_full
464
+
465
 
466
  @app.route('/api/umap', methods=['GET', 'POST'])
467
  def get_umap():
468
  """Compute and return UMAP projection with metadata."""
469
  try:
470
+ filter_methods = None
471
  if request.method == 'POST':
472
  data = request.get_json() or {}
473
  weights = data.get('weights', DEFAULT_WEIGHTS)
474
+ filter_methods = data.get('filterMethods', None)
475
  print(f"Using custom weights: {weights}")
476
+ if filter_methods:
477
+ print(f"Filtering to {len(filter_methods)} methods: {filter_methods}")
478
  else:
479
  weights = DEFAULT_WEIGHTS
480
  print(f"Using default weights")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
+ response_data, clustering_info, df, df_full = run_umap_pipeline(weights, filter_methods)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
 
484
  return jsonify({
485
  'success': True,
 
487
  'config': {
488
  'weights': weights,
489
  'defaultWeights': DEFAULT_WEIGHTS,
490
+ 'derivedColumns': DERIVED_COLUMNS,
491
  'n_neighbors': UMAP_N_NEIGHBORS,
492
  'min_dist': UMAP_MIN_DIST,
493
  'metric': UMAP_METRIC
494
  },
495
  'clustering': {
496
+ 'n_clusters': clustering_info['n_clusters'],
497
+ 'value_cluster_map': clustering_info['value_cluster_map']
498
+ },
499
+ 'filter': {
500
+ 'active': filter_methods is not None,
501
+ 'methods': filter_methods,
502
+ 'total': len(df_full)
503
  }
504
  })
505
+
506
+ except ValueError as e:
507
+ return jsonify({'success': False, 'error': str(e)}), 400
508
  except Exception as e:
509
  print(f"Error: {e}")
510
  import traceback
511
  traceback.print_exc()
512
  return jsonify({'success': False, 'error': str(e)}), 500
513
 
514
+ def build_schema_context(df):
515
+ """Build schema context string: column names, valid values, defaults."""
516
+ column_values = {}
517
+ for col in DEFAULT_WEIGHTS.keys():
518
+ if col == 'Description':
519
+ continue
520
+ if col in df.columns:
521
+ all_vals = set()
522
+ for val in df[col].fillna('').astype(str):
523
+ for part in smart_split(val):
524
+ all_vals.add(part)
525
+ column_values[col] = sorted(all_vals - {''})
526
+
527
+ derived_info = '\n'.join([
528
+ 'Derived columns (metadata-only, available for color-by):',
529
+ '- Grasp Dimensionality: 6-DoF, 7-DoF, 2D, Policy, Evaluation, Other',
530
+ '- Learning Paradigm: Classical, Learning-based, RL-based, Hybrid',
531
+ '- Sensor Complexity: 3D, 2.5D, 2D, Multimodal',
532
+ '- Scene Difficulty: Singulated, Structured, Cluttered, Packed, Piled',
533
+ '- Gripper Type: Parallel-jaw, Dexterous, Suction, Multi-gripper',
534
+ '- ML Framework: PyTorch, TensorFlow, Keras, None',
535
+ '- Method Era: Pioneer (2016-2018), Growth (2019-2021), Modern (2022+)',
536
+ ])
537
+
538
+ return f"""HOW THE TOOL WORKS:
539
+ - 13 columns are used to compute a weighted feature matrix (TF-IDF for categorical columns, sentence-transformer embeddings for Description).
540
+ - Each column has a weight (0-20). Higher weight = that column has more influence on which methods appear close together in the 2D UMAP projection.
541
+ - Weight 0 disables a column entirely.
542
+ - After weighting, UMAP projects to 2D and K-Means assigns clusters.
543
+ - The user can "color by" any column to see patterns.
544
+
545
+ WEIGHTED COLUMNS AND THEIR POSSIBLE VALUES:
546
+ {json.dumps(column_values, indent=2)}
547
+
548
+ Description: Free-text describing each method (weighted via sentence-transformer embeddings).
549
+
550
+ {derived_info}
551
+
552
+ DEFAULT WEIGHTS:
553
+ {json.dumps(dict(DEFAULT_WEIGHTS), indent=2)}"""
554
+
555
+
556
+ def build_method_summaries(df):
557
+ """Build compact one-line summaries of all methods."""
558
+ summaries = []
559
+ for _, row in df.iterrows():
560
+ name = row.get('Name', '')
561
+ parts = []
562
+ for col in DEFAULT_WEIGHTS.keys():
563
+ if col == 'Description':
564
+ continue
565
+ val = str(row.get(col, '')) if pd.notna(row.get(col, '')) else ''
566
+ if val:
567
+ short = SHORT_COLUMN_NAMES.get(col, col)
568
+ parts.append(f"{short}={val}")
569
+ summaries.append(f"- {name}: {'; '.join(parts)}")
570
+ return '\n'.join(summaries)
571
+
572
+
573
+ def retrieve_relevant_chunks(query):
574
+ """Placeholder for RAG retrieval. Returns empty string.
575
+ Future: query ChromaDB for relevant paper chunks."""
576
+ return ""
577
+
578
+
579
+ def build_ai_system_prompt(df, query):
580
+ """Assemble the full system prompt for the AI copilot."""
581
+ schema = build_schema_context(df)
582
+ methods = build_method_summaries(df)
583
+
584
+ retrieved = ""
585
+ if USE_RAG:
586
+ retrieved = retrieve_relevant_chunks(query)
587
+ if retrieved:
588
+ retrieved = f"\n\nRELEVANT PAPER EXCERPTS:\n{retrieved}"
589
+
590
+ return f"""You are an AI copilot for the Grasp Planner Explorer, a visualization tool that shows 56 robotic grasp planning methods projected via weighted UMAP.
591
+
592
+ {schema}
593
+
594
+ ALL {len(df)} METHODS:
595
+ {methods}
596
+ {retrieved}
597
+
598
+ YOUR TASK (Pass 1 — Configuration):
599
+ Given a natural language query from a researcher, respond with a JSON object containing:
600
+ 1. "filterMethods" - Array of method names to FILTER the dataset to. Only these methods will be shown in the UMAP projection and clustered together. Include ALL methods that match the query criteria (not just the best ones). If the query doesn't ask for filtering, include all method names. Use exact names from the dataset.
601
+ 2. "weights" - Complete weight dictionary (all 13 columns, values 0-20). Adjust weights to make the UMAP projection most useful for the query. Keep weights you don't need to change at their default values.
602
+ 3. "colorBy" - Which column to color by (must be one of the weighted column names, a derived column name, "cluster", or "index"). Pick the one most informative for the query.
603
+ 4. "highlightMethods" - Array of method names (subset of filterMethods) that deserve visual emphasis. Include 3-8 methods. HOW TO CHOOSE depends on query type:
604
+ - For SEARCH queries ("find methods for X"): highlight the strongest matches for X.
605
+ - For COMPARISON queries ("how do X and Y differ?"): highlight representative examples from EACH side — e.g., 3-4 examples of X AND 3-4 examples of Y so the user sees both groups.
606
+ - For EXPLORATION queries ("overview of the field"): highlight diverse, well-known methods spanning different clusters.
607
+
608
+ FILTERING GUIDELINES:
609
+ - When the query specifies attributes (e.g., "cluttered scenes"), filter to methods that have those attributes.
610
+ - Be INCLUSIVE — include methods that partially match. Better to show a few extra than miss relevant ones.
611
+ - When the query is comparative or exploratory (e.g., "compare sim vs real", "overview"), do NOT filter — include all methods so both sides are visible.
612
+ - For comparison queries, the full unfiltered dataset is essential to see the contrast.
613
+
614
+ IMPORTANT RULES:
615
+ - Use EXACT column names as keys in the weights dict (including trailing spaces and long names like "Corresponding Dataset (see repository linked above)").
616
+ - Only use method names that actually exist in the dataset.
617
+ - When the query is about comparing methods along a dimension, suggest coloring by that dimension.
618
+ - For comparison queries, increase the weight of the compared dimension so UMAP separates the groups clearly.
619
+ - Respond with ONLY the JSON object, no markdown fences, no explanation outside the JSON."""
620
+
621
+
622
+ def build_cluster_stats(response_data, clustering_info, weights):
623
+ """Build structured cluster stats for both AI context and frontend legend.
624
+ Returns (summary_text, cluster_stats_list)."""
625
+ from collections import Counter
626
+ cluster_labels = clustering_info['cluster_labels']
627
+ value_cluster_map = clustering_info['value_cluster_map']
628
+
629
+ # Group methods by cluster
630
+ clusters = {}
631
+ for point in response_data:
632
+ c = point['cluster']
633
+ if c not in clusters:
634
+ clusters[c] = []
635
+ clusters[c].append(point)
636
+
637
+ # Key columns for characterizing clusters (most interpretable)
638
+ key_cols = ['Planning Method', 'End-effector Hardware', 'Object Configuration',
639
+ 'Input Data', 'Training Data']
640
+ weighted_cols = [col for col, w in weights.items() if w > 0 and col != 'Description']
641
+
642
+ lines = [f"CLUSTERING RESULTS ({len(response_data)} methods in {len(clusters)} clusters):\n"]
643
+ cluster_stats_list = []
644
+
645
+ for cluster_id in sorted(clusters.keys()):
646
+ members = clusters[cluster_id]
647
+ names = [m['name'] for m in members]
648
+ lines.append(f"Cluster {cluster_id} ({len(members)} methods): {', '.join(names)}")
649
+
650
+ stat = {
651
+ 'id': cluster_id,
652
+ 'methods': names,
653
+ 'size': len(members),
654
+ 'topAttributes': {}
655
+ }
656
+
657
+ for col in weighted_cols:
658
+ vals = []
659
+ for m in members:
660
+ raw = m['metadata'].get(col, '')
661
+ if raw:
662
+ for part in smart_split(raw):
663
+ vals.append(part)
664
+ if vals:
665
+ counts = Counter(vals)
666
+ top = counts.most_common(3)
667
+ summary = ', '.join([f"{v} ({c})" for v, c in top])
668
+ short = SHORT_COLUMN_NAMES.get(col, col)
669
+ lines.append(f" {short}: {summary}")
670
+ # Only include key columns in frontend stats for readability
671
+ if col in key_cols:
672
+ stat['topAttributes'][short] = [{'value': v, 'count': c} for v, c in top]
673
+ lines.append("")
674
+
675
+ # Generate a short characterization for the legend
676
+ dominant_attrs = []
677
+ for col in key_cols[:3]:
678
+ short = SHORT_COLUMN_NAMES.get(col, col)
679
+ if short in stat['topAttributes'] and stat['topAttributes'][short]:
680
+ dominant_attrs.append(stat['topAttributes'][short][0]['value'])
681
+ stat['label'] = ' / '.join(dominant_attrs) if dominant_attrs else f'Cluster {cluster_id}'
682
+ cluster_stats_list.append(stat)
683
+
684
+ # Add value-cluster associations
685
+ lines.append("DOMINANT CLUSTER PER VALUE (which cluster each attribute value is most associated with):")
686
+ for col, mapping in value_cluster_map.items():
687
+ if mapping:
688
+ short = SHORT_COLUMN_NAMES.get(col, col)
689
+ pairs = [f"{v}→C{c}" for v, c in sorted(mapping.items())]
690
+ lines.append(f" {short}: {', '.join(pairs)}")
691
+
692
+ return '\n'.join(lines), cluster_stats_list
693
+
694
+
695
+ def build_insight_prompt(query, response_data, clustering_info, weights, color_by, highlight_methods, filter_methods):
696
+ """Build the Pass 2 prompt that asks the AI to interpret clustering results."""
697
+ cluster_summary, _ = build_cluster_stats(response_data, clustering_info, weights)
698
+
699
+ return f"""You are an AI copilot for the Grasp Planner Explorer, a visualization tool for robotic grasp planning methods.
700
+
701
+ {DOMAIN_CONTEXT}
702
+
703
+ You have just configured the visualization based on a researcher's query, and UMAP + K-Means clustering have been computed.
704
+
705
+ RESEARCHER'S QUERY: {query}
706
+
707
+ WHAT YOU DID:
708
+ - Filtered to {len(response_data)} methods{' (from 56 total)' if filter_methods else ''}
709
+ - Colored by: {color_by}
710
+ - Highlighted {len(highlight_methods)} best matches: {', '.join(highlight_methods)}
711
+ - Weights adjusted: {json.dumps({k: v for k, v in weights.items() if v != 10})}
712
+
713
+ {cluster_summary}
714
+
715
+ YOUR TASK (Pass 2 — Insight):
716
+ Based on the ACTUAL clustering results and your domain knowledge, write concise bullet points. Format as bullet points starting with "- ".
717
+
718
+ Write 3-5 bullet points that:
719
+ - Explain WHY methods cluster together using domain knowledge (e.g., "these methods share a sampling-based approach which requires different input representations than direct regression methods")
720
+ - Point out meaningful patterns (not just "Cluster X has Y" but WHY that grouping matters)
721
+ - Call attention to the highlighted best-match methods — where did they land and what does that tell us?
722
+ - Note any surprising groupings or trade-offs the researcher should be aware of
723
+
724
+ Be specific — reference method names, cluster numbers, and attribute values. Ground insights in both the data AND domain knowledge.
725
+
726
+ Respond with ONLY the bullet points, no JSON, no markdown fences, no headers."""
727
+
728
+
729
+ @app.route('/api/ai-query', methods=['POST'])
730
+ def ai_query():
731
+ """Two-pass AI copilot:
732
+ Pass 1: LLM decides filter, weights, colorBy, highlights (from raw metadata)
733
+ Pass 2: Run UMAP/clustering, feed results back to LLM for grounded insight
734
+ """
735
+ response_text = ''
736
+ try:
737
+ data = request.get_json() or {}
738
+ query = data.get('query', '').strip()
739
+ if not query:
740
+ return jsonify({'success': False, 'error': 'Empty query'}), 400
741
+
742
+ current_weights = data.get('currentWeights', DEFAULT_WEIGHTS)
743
+ current_color_by = data.get('currentColorBy', 'cluster')
744
+
745
+ df = pd.read_csv(CSV_FILE)
746
+ valid_names = set(df['Name'].tolist())
747
+
748
+ # ── Pass 1: Decide configuration ──────────────────────────────
749
+ print(f"[Pass 1] Query: '{query}'")
750
+ system_prompt = build_ai_system_prompt(df, query)
751
+ user_message = f"""Current weights: {json.dumps(current_weights)}
752
+ Current color-by: {current_color_by}
753
+
754
+ Researcher's query: {query}"""
755
+
756
+ response_text = llm_chat([
757
+ {'role': 'system', 'content': system_prompt},
758
+ {'role': 'user', 'content': user_message}
759
+ ])
760
+
761
+ # Handle potential markdown fences
762
+ if response_text.startswith('```'):
763
+ lines = response_text.split('\n')
764
+ response_text = '\n'.join(lines[1:-1])
765
+
766
+ result = json.loads(response_text)
767
+
768
+ # Validate required fields (insight no longer required from Pass 1)
769
+ required = ['weights', 'colorBy', 'highlightMethods']
770
+ for field in required:
771
+ if field not in result:
772
+ return jsonify({
773
+ 'success': False,
774
+ 'error': f'AI response missing field: {field}'
775
+ }), 500
776
+
777
+ # Validate filterMethods
778
+ if 'filterMethods' in result:
779
+ result['filterMethods'] = [
780
+ m for m in result['filterMethods'] if m in valid_names
781
+ ]
782
+ if not result['filterMethods'] or len(result['filterMethods']) >= len(valid_names):
783
+ result['filterMethods'] = None
784
+ else:
785
+ result['filterMethods'] = None
786
+
787
+ result['highlightMethods'] = [
788
+ m for m in result['highlightMethods'] if m in valid_names
789
+ ]
790
+
791
+ # Clamp weights to 0-20
792
+ for col in result['weights']:
793
+ result['weights'][col] = max(0, min(20, int(result['weights'][col])))
794
+
795
+ print(f"[Pass 1] Filter: {len(result['filterMethods']) if result['filterMethods'] else 'none'}, "
796
+ f"Highlights: {len(result['highlightMethods'])}, ColorBy: {result['colorBy']}")
797
+
798
+ # ── Run UMAP/Clustering pipeline ──────────────────────────────
799
+ print("[Pipeline] Running UMAP + K-Means on AI-configured data...")
800
+ response_data, clustering_info, _, _ = run_umap_pipeline(
801
+ result['weights'], result['filterMethods']
802
+ )
803
+ print(f"[Pipeline] Done: {len(response_data)} methods, {clustering_info['n_clusters']} clusters")
804
+
805
+ # ── Pass 2: Generate grounded insight ─────────────────────────
806
+ print("[Pass 2] Generating insight from clustering results...")
807
+ insight_prompt = build_insight_prompt(
808
+ query, response_data, clustering_info,
809
+ result['weights'], result['colorBy'],
810
+ result['highlightMethods'], result['filterMethods']
811
+ )
812
+
813
+ insight_text = llm_chat([
814
+ {'role': 'user', 'content': insight_prompt}
815
+ ], max_tokens=1024)
816
+ # Clean up any markdown formatting
817
+ if insight_text.startswith('```'):
818
+ lines = insight_text.split('\n')
819
+ insight_text = '\n'.join(lines[1:-1])
820
+ result['insight'] = insight_text
821
+
822
+ print(f"[Pass 2] Insight generated ({len(insight_text)} chars)")
823
+
824
+ # Include the UMAP data and cluster stats in the response
825
+ _, cluster_stats = build_cluster_stats(response_data, clustering_info, result['weights'])
826
+ result['umapData'] = response_data
827
+ result['clustering'] = {
828
+ 'n_clusters': clustering_info['n_clusters'],
829
+ 'value_cluster_map': clustering_info['value_cluster_map']
830
+ }
831
+ result['clusterStats'] = cluster_stats
832
+
833
+ return jsonify({'success': True, **result})
834
+
835
+ except json.JSONDecodeError as e:
836
+ return jsonify({
837
+ 'success': False,
838
+ 'error': f'Failed to parse AI response as JSON: {str(e)}',
839
+ 'raw_response': response_text
840
+ }), 500
841
+ except Exception as e:
842
+ print(f"AI query error: {e}")
843
+ import traceback
844
+ traceback.print_exc()
845
+ return jsonify({'success': False, 'error': str(e)}), 500
846
+
847
+
848
+ @app.route('/api/cluster-insight', methods=['POST'])
849
+ def cluster_insight():
850
+ """Generate AI insight for the current clustering (used on initial page load)."""
851
+ try:
852
+ data = request.get_json() or {}
853
+ umap_data = data.get('umapData', [])
854
+ clustering = data.get('clustering', {})
855
+ weights = data.get('weights', DEFAULT_WEIGHTS)
856
+
857
+ if not umap_data:
858
+ return jsonify({'success': False, 'error': 'No UMAP data provided'}), 400
859
+
860
+ clustering_info = {
861
+ 'n_clusters': clustering.get('n_clusters', 11),
862
+ 'cluster_labels': [p['cluster'] for p in umap_data],
863
+ 'value_cluster_map': clustering.get('value_cluster_map', {})
864
+ }
865
+
866
+ cluster_summary, cluster_stats = build_cluster_stats(umap_data, clustering_info, weights)
867
+
868
+ prompt = f"""You are an AI copilot for the Grasp Planner Explorer, a visualization tool for robotic grasp planning methods.
869
+
870
+ {DOMAIN_CONTEXT}
871
+
872
+ The researcher has just opened the tool and sees {len(umap_data)} methods projected via weighted UMAP and grouped by K-Means clustering. Give them an orientation.
873
+
874
+ {cluster_summary}
875
+
876
+ YOUR TASK:
877
+ Write 4-6 bullet points summarizing what the clustering reveals about the field of robotic grasp planning. Format as bullet points starting with "- ".
878
+
879
+ Each bullet should:
880
+ - Explain WHY methods group together using domain knowledge (not just "Cluster X has Y")
881
+ - Reference specific cluster numbers, method names, and attribute values
882
+ - Help the researcher build a mental map of the landscape
883
+
884
+ Write in a welcoming, informative tone.
885
+
886
+ Respond with ONLY the bullet points, no JSON, no markdown fences, no headers."""
887
+
888
+ insight = llm_chat([{'role': 'user', 'content': prompt}], max_tokens=1024)
889
+ if insight.startswith('```'):
890
+ lines = insight.split('\n')
891
+ insight = '\n'.join(lines[1:-1])
892
+
893
+ return jsonify({'success': True, 'insight': insight, 'clusterStats': cluster_stats})
894
+
895
+ except Exception as e:
896
+ print(f"Cluster insight error: {e}")
897
+ import traceback
898
+ traceback.print_exc()
899
+ return jsonify({'success': False, 'error': str(e)}), 500
900
+
901
+
902
  @app.route('/api/health')
903
  def health():
904
  """Health check endpoint."""
 
916
 
917
  if __name__ == '__main__':
918
  port = int(os.environ.get('PORT', 5005))
919
+ debug = False # disabled torch + Flask reloader causes fork crashes on macOS
920
  print(f"Starting Flask server on http://0.0.0.0:{port}")
921
  print(f"CSV file: {CSV_FILE}")
922
  print(f"Default weights: {DEFAULT_WEIGHTS}")
backend/requirements.txt CHANGED
@@ -4,3 +4,4 @@ pandas==2.1.4
4
  numpy==1.26.2
5
  scikit-learn==1.3.2
6
  umap-learn==0.5.5
 
 
4
  numpy==1.26.2
5
  scikit-learn==1.3.2
6
  umap-learn==0.5.5
7
+ sentence-transformers>=2.2.0
frontend/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/package.json CHANGED
@@ -1,14 +1,18 @@
1
  {
2
- "name": "grasp-planner-umap",
3
  "version": "0.1.0",
4
  "private": true,
5
  "dependencies": {
6
- "react": "^18.2.0",
7
- "react-dom": "^18.2.0",
8
- "react-scripts": "5.0.1",
9
- "plotly.js": "^2.27.1",
 
 
 
10
  "react-plotly.js": "^2.6.0",
11
- "axios": "^1.6.2"
 
12
  },
13
  "scripts": {
14
  "start": "react-scripts start",
@@ -16,12 +20,23 @@
16
  "test": "react-scripts test",
17
  "eject": "react-scripts eject"
18
  },
19
- "proxy": "http://localhost:5005",
20
  "eslintConfig": {
21
- "extends": ["react-app"]
 
 
 
22
  },
 
23
  "browserslist": {
24
- "production": [">0.2%", "not dead", "not op_mini all"],
25
- "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
 
 
 
 
 
 
 
 
26
  }
27
  }
 
1
  {
2
+ "name": "grasp-explorer",
3
  "version": "0.1.0",
4
  "private": true,
5
  "dependencies": {
6
+ "@testing-library/dom": "^10.4.1",
7
+ "@testing-library/jest-dom": "^6.9.1",
8
+ "@testing-library/react": "^16.3.2",
9
+ "@testing-library/user-event": "^13.5.0",
10
+ "plotly.js": "^3.4.0",
11
+ "react": "^19.2.4",
12
+ "react-dom": "^19.2.4",
13
  "react-plotly.js": "^2.6.0",
14
+ "react-scripts": "5.0.1",
15
+ "web-vitals": "^2.1.4"
16
  },
17
  "scripts": {
18
  "start": "react-scripts start",
 
20
  "test": "react-scripts test",
21
  "eject": "react-scripts eject"
22
  },
 
23
  "eslintConfig": {
24
+ "extends": [
25
+ "react-app",
26
+ "react-app/jest"
27
+ ]
28
  },
29
+ "proxy": "http://localhost:5005",
30
  "browserslist": {
31
+ "production": [
32
+ ">0.2%",
33
+ "not dead",
34
+ "not op_mini all"
35
+ ],
36
+ "development": [
37
+ "last 1 chrome version",
38
+ "last 1 firefox version",
39
+ "last 1 safari version"
40
+ ]
41
  }
42
  }
frontend/public/favicon.ico ADDED
frontend/public/index.html CHANGED
@@ -1,11 +1,14 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Grasp Planner UMAP</title>
7
- </head>
8
- <body>
9
- <div id="root"></div>
10
- </body>
 
 
 
11
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="description" content="Interactive visualization of robotic grasp planning methods" />
8
+ <title>Grasp Explorer</title>
9
+ </head>
10
+ <body>
11
+ <noscript>You need to enable JavaScript to run this app.</noscript>
12
+ <div id="root"></div>
13
+ </body>
14
  </html>
frontend/src/App.css CHANGED
@@ -1,61 +1,400 @@
1
- body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; }
2
- .App { min-height: 100vh; }
3
- .App-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1.5rem; text-align: center; }
4
- .App-header h1 { margin: 0; font-size: 1.8rem; }
5
-
6
- /* Controls bar */
7
- .controls { display: flex; align-items: center; gap: 2rem; padding: 1rem 2rem; background: white; border-bottom: 1px solid #e0e0e0; }
8
- .control-group { display: flex; align-items: center; gap: 0.5rem; }
9
- .control-group label { font-weight: 600; color: #555; }
10
- .control-group select { padding: 0.5rem 1rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.9rem; }
11
- .selection-info { display: flex; align-items: center; gap: 1rem; color: #667eea; font-weight: 600; }
12
- .selection-info button { padding: 0.4rem 0.8rem; background: #667eea; color: white; border: none; border-radius: 4px; cursor: pointer; }
13
- .selection-info button:hover { background: #5a6fd6; }
14
-
15
- /* Main content grid */
16
- .main-content { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; height: 560px; }
17
- .plot-section { background: white; border-radius: 8px; padding: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1); display: flex; flex-direction: column; overflow: hidden; }
18
- .hint { text-align: center; color: #888; font-size: 0.85rem; margin: 0.5rem 0 0 0; }
19
-
20
- /* Table section */
21
- .table-section { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden; display: flex; flex-direction: column; }
22
- .table-header-controls { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: #f8f9fa; border-bottom: 1px solid #dee2e6; }
23
- .table-title { font-weight: 600; color: #495057; }
24
- .weight-controls { display: flex; gap: 0.5rem; }
25
- .reset-btn { padding: 0.4rem 0.8rem; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
26
- .reset-btn:hover { background: #5a6268; }
27
- .recompute-btn { padding: 0.4rem 1rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.85rem; }
28
- .recompute-btn:hover { background: #218838; }
29
- .recompute-btn:disabled { background: #94d3a2; cursor: wait; }
30
- .table-container { flex: 1; overflow: auto; }
31
- table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
32
- thead { position: sticky; top: 0; background: #f8f9fa; z-index: 10; }
33
- .weight-row { background: #e8f4ea !important; }
34
- .weight-row th { padding: 0.4rem 0.3rem; border-bottom: 1px solid #dee2e6; }
35
- .weight-header { font-size: 0.75rem; color: #28a745; font-weight: 600; white-space: nowrap; }
36
- .weight-cell input { width: 40px; padding: 0.25rem; border: 1px solid #ccc; border-radius: 3px; text-align: center; font-size: 0.8rem; font-weight: 600; }
37
- .weight-cell input:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); }
38
- .weight-cell input.zero-weight { background: #f8d7da; color: #721c24; border-color: #f5c6cb; }
39
- th { padding: 0.6rem 0.5rem; text-align: left; border-bottom: 2px solid #dee2e6; cursor: pointer; white-space: nowrap; font-weight: 600; color: #495057; }
40
- th:hover { background: #e9ecef; }
41
- th.sorted { background: #e3e8ff; color: #667eea; }
42
- th.disabled-col, td.disabled-col { opacity: 0.4; background: #f8f9fa; }
43
- td { padding: 0.5rem; border-bottom: 1px solid #eee; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44
- .name-cell { max-width: 180px; font-weight: 500; }
45
- .empty { color: #ccc; }
46
- tr:hover { background: #f0f7ff; }
47
- tr.hovered { background: #fff3cd !important; }
48
- tr.selected { background: #d4edda !important; }
49
- tbody tr { cursor: pointer; }
50
-
51
- /* Detail panel */
52
- .detail-panel { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 3px solid #667eea; padding: 1rem 2rem; max-height: 200px; overflow-y: auto; box-shadow: 0 -4px 12px rgba(0,0,0,0.15); z-index: 100; }
53
- .detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
54
- .detail-header h2 { margin: 0; font-size: 1.2rem; color: #333; }
55
- .detail-header button { font-size: 1.5rem; background: none; border: none; cursor: pointer; color: #999; }
56
- .detail-header button:hover { color: #333; }
57
- .detail-content p { margin: 0; color: #555; line-height: 1.5; }
58
-
59
- /* Loading/error states */
60
- .loading, .error { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; }
61
- .error { color: #dc3545; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ body {
4
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5
+ background: #f5f6fa;
6
+ color: #333;
7
+ }
8
+
9
+ .copilot-app {
10
+ max-width: 1400px;
11
+ margin: 0 auto;
12
+ padding: 1rem;
13
+ }
14
+
15
+ /* Header */
16
+ .copilot-header { margin-bottom: 0.75rem; }
17
+ .header-content { display: flex; align-items: center; gap: 0.75rem; }
18
+ .copilot-header h1 { font-size: 1.3rem; font-weight: 700; color: #1a1a2e; }
19
+ .badge {
20
+ font-size: 0.6rem; background: #667eea; color: white;
21
+ padding: 0.15rem 0.5rem; border-radius: 10px; font-weight: 600;
22
+ text-transform: uppercase; letter-spacing: 0.5px;
23
+ }
24
+
25
+ /* Query */
26
+ .query-section { margin-bottom: 0.75rem; }
27
+ .query-form { display: flex; gap: 0.5rem; }
28
+ .query-input {
29
+ flex: 1; padding: 0.6rem 0.75rem; border: 2px solid #e0e0e0;
30
+ border-radius: 6px; font-size: 0.9rem;
31
+ }
32
+ .query-input:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102,126,234,0.15); }
33
+ .query-input:disabled { background: #f0f0f0; }
34
+ .query-btn {
35
+ padding: 0.6rem 1.2rem; background: #667eea; color: white; border: none;
36
+ border-radius: 6px; font-weight: 600; cursor: pointer; white-space: nowrap;
37
+ }
38
+ .query-btn:hover:not(:disabled) { background: #5a6fd6; }
39
+ .query-btn:disabled { background: #b0b9e8; cursor: not-allowed; }
40
+
41
+ .query-error {
42
+ margin-top: 0.5rem; padding: 0.4rem 0.75rem; background: #fef2f2;
43
+ color: #991b1b; border: 1px solid #fecaca; border-radius: 4px; font-size: 0.82rem;
44
+ }
45
+
46
+ /* Insight Card */
47
+ .insight-card {
48
+ margin-top: 0.75rem; padding: 0; background: white;
49
+ border-radius: 8px; border: 1px solid #d0d9f5;
50
+ box-shadow: 0 2px 8px rgba(102,126,234,0.12);
51
+ overflow: hidden;
52
+ }
53
+ .insight-card-header {
54
+ display: flex; align-items: center; gap: 0.5rem;
55
+ padding: 0.5rem 0.75rem; background: linear-gradient(135deg, #667eea, #764ba2);
56
+ color: white;
57
+ }
58
+ .insight-icon {
59
+ font-size: 0.65rem; font-weight: 700; background: rgba(255,255,255,0.25);
60
+ padding: 0.15rem 0.4rem; border-radius: 4px; letter-spacing: 0.5px;
61
+ }
62
+ .insight-title { font-weight: 600; font-size: 0.85rem; flex: 1; }
63
+ .insight-close {
64
+ background: none; border: none; color: rgba(255,255,255,0.7);
65
+ font-size: 1.2rem; cursor: pointer; line-height: 1;
66
+ }
67
+ .insight-close:hover { color: white; }
68
+
69
+ .insight-body { padding: 0.6rem 0.75rem; }
70
+ .insight-text { color: #333; line-height: 1.5; font-size: 0.88rem; margin: 0; }
71
+
72
+ .insight-actions-summary {
73
+ display: flex; flex-wrap: wrap; gap: 0.4rem;
74
+ padding: 0 0.75rem 0.5rem;
75
+ }
76
+ .action-chip {
77
+ font-size: 0.75rem; font-weight: 600; padding: 0.2rem 0.5rem;
78
+ border-radius: 12px;
79
+ }
80
+ .filter-chip { background: #ecfdf5; color: #059669; border: 1px solid #a7f3d0; }
81
+ .highlight-chip { background: #eef2ff; color: #667eea; border: 1px solid #c7d2fe; }
82
+ .weight-change-chip { background: #fff7ed; color: #c2410c; border: 1px solid #fed7aa; }
83
+
84
+ .insight-weight-details {
85
+ display: flex; flex-wrap: wrap; gap: 0.25rem;
86
+ padding: 0 0.75rem 0.5rem;
87
+ }
88
+ .weight-chip {
89
+ background: #f0f4ff; padding: 0.15rem 0.4rem; border-radius: 3px;
90
+ font-size: 0.75rem; border: 1px solid #d0d9f5; color: #444;
91
+ }
92
+ .insight-matches {
93
+ padding: 0 0.75rem 0.6rem; font-size: 0.8rem; color: #555;
94
+ font-style: italic;
95
+ }
96
+ .matches-label { font-weight: 600; font-style: normal; margin-right: 0.3rem; }
97
+
98
+ /* Status Bar */
99
+ .status-bar {
100
+ padding: 0.4rem 0.75rem; background: #f0f4ff; border-radius: 5px;
101
+ margin-bottom: 0.5rem; font-size: 0.82rem; color: #555;
102
+ display: flex; align-items: center; gap: 1rem;
103
+ }
104
+ .status-computing { color: #667eea; font-weight: 500; }
105
+ .status-filter { color: #059669; font-weight: 500; }
106
+ .status-highlights { color: #667eea; }
107
+ .reset-btn {
108
+ margin-left: auto; background: white; border: 1px solid #ccc;
109
+ border-radius: 4px; padding: 0.2rem 0.6rem; font-size: 0.78rem;
110
+ cursor: pointer; color: #555;
111
+ }
112
+ .reset-btn:hover { background: #f3f4f6; }
113
+
114
+ /* Main Content: Side by Side */
115
+ .main-content {
116
+ display: flex;
117
+ gap: 0.75rem;
118
+ margin-bottom: 0.75rem;
119
+ min-height: 480px;
120
+ }
121
+
122
+ .scatter-panel {
123
+ flex: 1;
124
+ min-width: 0;
125
+ background: white;
126
+ border-radius: 8px;
127
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
128
+ overflow: hidden;
129
+ display: flex;
130
+ flex-direction: column;
131
+ }
132
+
133
+ /* Cluster Insight Card (below scatter on initial load) */
134
+ .cluster-insight-card {
135
+ border-top: 1px solid #e5e7eb;
136
+ background: #fafbff;
137
+ }
138
+ .cluster-insight-header {
139
+ display: flex; align-items: center; gap: 0.4rem;
140
+ padding: 0.4rem 0.75rem;
141
+ background: linear-gradient(135deg, #667eea22, #764ba222);
142
+ border-bottom: 1px solid #e0e4f0;
143
+ }
144
+ .cluster-insight-icon {
145
+ font-size: 0.6rem; font-weight: 700; background: #667eea;
146
+ color: white; padding: 0.1rem 0.35rem; border-radius: 3px;
147
+ letter-spacing: 0.5px;
148
+ }
149
+ .cluster-insight-title {
150
+ font-weight: 600; font-size: 0.8rem; color: #444;
151
+ }
152
+ .cluster-insight-body {
153
+ padding: 0.5rem 0.75rem;
154
+ }
155
+ .cluster-insight-body p {
156
+ font-size: 0.82rem; line-height: 1.55; color: #444; margin: 0;
157
+ }
158
+ .cluster-insight-loading {
159
+ color: #667eea; font-style: italic;
160
+ }
161
+
162
+ /* Cluster Legend */
163
+ .cluster-legend {
164
+ border-top: 1px solid #e5e7eb;
165
+ padding: 0.5rem 0.75rem;
166
+ }
167
+ .cluster-legend-title {
168
+ font-size: 0.75rem; font-weight: 600; color: #555;
169
+ text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.4rem;
170
+ }
171
+ .cluster-legend-grid {
172
+ display: grid;
173
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
174
+ gap: 0.4rem;
175
+ }
176
+ .cluster-legend-item {
177
+ padding: 0.35rem 0.5rem;
178
+ background: white;
179
+ border: 1px solid #e5e7eb;
180
+ border-radius: 5px;
181
+ font-size: 0.72rem;
182
+ }
183
+ .cluster-legend-header {
184
+ display: flex; align-items: center; gap: 0.3rem; margin-bottom: 0.15rem;
185
+ }
186
+ .cluster-color-dot {
187
+ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
188
+ }
189
+ .cluster-legend-name {
190
+ font-weight: 700; color: #333;
191
+ }
192
+ .cluster-legend-count {
193
+ color: #888; margin-left: auto; font-size: 0.68rem;
194
+ }
195
+ .cluster-legend-label {
196
+ font-weight: 600; color: #444; margin-bottom: 0.1rem;
197
+ }
198
+ .cluster-legend-methods {
199
+ color: #777; font-size: 0.68rem; line-height: 1.3;
200
+ }
201
+
202
+ /* Insight bullet points */
203
+ .insight-bullets {
204
+ margin: 0; padding: 0 0 0 1.2rem;
205
+ list-style: none;
206
+ }
207
+ .insight-bullets li {
208
+ font-size: 0.82rem; line-height: 1.55; color: #444;
209
+ margin-bottom: 0.35rem; position: relative; padding-left: 0.4rem;
210
+ }
211
+ .insight-bullets li::before {
212
+ content: '';
213
+ position: absolute; left: -0.9rem; top: 0.5em;
214
+ width: 5px; height: 5px; border-radius: 50%;
215
+ background: #667eea;
216
+ }
217
+ .insight-body .insight-bullets li {
218
+ font-size: 0.85rem;
219
+ }
220
+
221
+ .table-panel {
222
+ width: 480px;
223
+ flex-shrink: 0;
224
+ background: white;
225
+ border-radius: 8px;
226
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
227
+ display: flex;
228
+ flex-direction: column;
229
+ overflow: hidden;
230
+ }
231
+
232
+ .table-panel-header {
233
+ padding: 0.5rem 0.75rem;
234
+ background: #f8fafc;
235
+ border-bottom: 1px solid #e5e7eb;
236
+ font-size: 0.82rem;
237
+ font-weight: 600;
238
+ color: #444;
239
+ display: flex;
240
+ justify-content: space-between;
241
+ align-items: center;
242
+ flex-shrink: 0;
243
+ }
244
+
245
+ .hl-indicator {
246
+ font-size: 0.75rem;
247
+ color: #667eea;
248
+ font-weight: 500;
249
+ }
250
+
251
+ .table-scroll {
252
+ overflow: auto;
253
+ flex: 1;
254
+ }
255
+
256
+ /* Data Table */
257
+ .data-table {
258
+ width: 100%;
259
+ border-collapse: collapse;
260
+ font-size: 0.75rem;
261
+ }
262
+
263
+ .data-table thead {
264
+ position: sticky;
265
+ top: 0;
266
+ z-index: 2;
267
+ }
268
+
269
+ .data-table th {
270
+ background: #f0f4ff;
271
+ padding: 0.4rem 0.5rem;
272
+ text-align: left;
273
+ font-weight: 600;
274
+ color: #444;
275
+ border-bottom: 2px solid #d0d9f5;
276
+ white-space: nowrap;
277
+ font-size: 0.72rem;
278
+ }
279
+
280
+ .data-table td {
281
+ padding: 0.35rem 0.5rem;
282
+ border-bottom: 1px solid #f0f0f0;
283
+ color: #333;
284
+ max-width: 140px;
285
+ overflow: hidden;
286
+ text-overflow: ellipsis;
287
+ white-space: nowrap;
288
+ }
289
+
290
+ .data-table .sticky-col {
291
+ position: sticky;
292
+ left: 0;
293
+ z-index: 1;
294
+ background: white;
295
+ }
296
+
297
+ .data-table thead .sticky-col {
298
+ background: #f0f4ff;
299
+ z-index: 3;
300
+ }
301
+
302
+ .data-table .name-cell {
303
+ font-weight: 600;
304
+ color: #1a1a2e;
305
+ min-width: 130px;
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 0.3rem;
309
+ }
310
+
311
+ .hl-dot {
312
+ width: 6px;
313
+ height: 6px;
314
+ border-radius: 50%;
315
+ background: #667eea;
316
+ flex-shrink: 0;
317
+ }
318
+
319
+ .data-table tbody tr {
320
+ cursor: pointer;
321
+ transition: background 0.1s;
322
+ }
323
+
324
+ .data-table tbody tr:hover,
325
+ .data-table tbody tr.row-hov {
326
+ background: #f5f7ff;
327
+ }
328
+ .data-table tbody tr:hover .sticky-col,
329
+ .data-table tbody tr.row-hov .sticky-col {
330
+ background: #f5f7ff;
331
+ }
332
+
333
+ .data-table tbody tr.row-hl {
334
+ background: #eef2ff;
335
+ }
336
+ .data-table tbody tr.row-hl .sticky-col {
337
+ background: #eef2ff;
338
+ }
339
+ .data-table tbody tr.row-hl:hover,
340
+ .data-table tbody tr.row-hl.row-hov {
341
+ background: #e0e7ff;
342
+ }
343
+ .data-table tbody tr.row-hl:hover .sticky-col,
344
+ .data-table tbody tr.row-hl.row-hov .sticky-col {
345
+ background: #e0e7ff;
346
+ }
347
+
348
+ .data-table tbody tr.row-sel {
349
+ background: #dbeafe;
350
+ }
351
+ .data-table tbody tr.row-sel .sticky-col {
352
+ background: #dbeafe;
353
+ }
354
+
355
+ /* Detail Panel */
356
+ .detail-panel {
357
+ background: white;
358
+ border-radius: 8px;
359
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
360
+ margin-bottom: 0.75rem;
361
+ overflow: hidden;
362
+ }
363
+
364
+ .detail-panel-header {
365
+ display: flex; justify-content: space-between; align-items: center;
366
+ padding: 0.6rem 0.75rem; background: #f8fafc; border-bottom: 1px solid #e5e7eb;
367
+ }
368
+ .detail-panel-header h3 { font-size: 0.95rem; color: #1a1a2e; }
369
+ .detail-panel-header button {
370
+ background: none; border: none; font-size: 1.3rem; color: #999; cursor: pointer;
371
+ }
372
+
373
+ .detail-panel-body { padding: 0.75rem; }
374
+ .detail-description { font-size: 0.85rem; line-height: 1.5; color: #444; margin-bottom: 0.5rem; }
375
+ .detail-metadata { display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 0.75rem; }
376
+ .metadata-row { font-size: 0.78rem; }
377
+ .metadata-key { font-weight: 600; color: #555; }
378
+ .metadata-val { color: #333; margin-left: 0.2rem; }
379
+
380
+ /* Loading / Error */
381
+ .loading-screen {
382
+ display: flex; flex-direction: column; align-items: center;
383
+ justify-content: center; min-height: 50vh; color: #666;
384
+ }
385
+ .spinner {
386
+ width: 32px; height: 32px; border: 3px solid #e0e0e0;
387
+ border-top-color: #667eea; border-radius: 50%;
388
+ animation: spin 0.8s linear infinite; margin-bottom: 0.75rem;
389
+ }
390
+ @keyframes spin { to { transform: rotate(360deg); } }
391
+ .error-screen { text-align: center; padding: 2rem; color: #991b1b; }
392
+
393
+ /* Footer */
394
+ .copilot-footer { text-align: center; padding: 0.75rem; font-size: 0.75rem; color: #999; }
395
+
396
+ /* Responsive */
397
+ @media (max-width: 900px) {
398
+ .main-content { flex-direction: column; }
399
+ .table-panel { width: 100%; max-height: 400px; }
400
+ }
frontend/src/App.js CHANGED
@@ -1,87 +1,56 @@
1
- import React, { useState, useEffect, useMemo, useCallback } from 'react';
2
- import Plot from 'react-plotly.js';
3
- import ClusterFeatureGrid from './ClusterFeatureGrid';
 
 
 
 
4
  import './App.css';
5
 
6
- // Columns that have weights (used for table and coloring)
7
- const WEIGHTED_COLUMNS = [
8
- 'Planning Method',
9
- 'Training Data',
10
- 'End-effector Hardware',
11
- 'Object Configuration',
12
- 'Input Data',
13
- 'Output Pose',
14
- 'Corresponding Dataset (see repository linked above)',
15
- 'Simulator (see repository linked above)',
16
- 'Backbone',
17
- 'Metric(s) Used ',
18
- 'Camera Position(s)',
19
- 'Language'
20
- ];
21
-
22
- // Shared cluster colors — used by both the scatter plot and the cluster-feature grid
23
- const CLUSTER_COLORS = [
24
- '#8dd3c7', '#ffffb3', '#bebada', '#fb8072', '#80b1d3',
25
- '#fdb462', '#b3de69', '#fccde5', '#d9d9d9', '#bc80bd', '#ccebc5'
26
- ];
27
-
28
- // Short display names for table headers
29
- const SHORT_NAMES = {
30
- 'Planning Method': 'Planning Method',
31
- 'Training Data': 'Training Data',
32
- 'End-effector Hardware': 'End-effector',
33
- 'Object Configuration': 'Object Config',
34
- 'Input Data': 'Input Data',
35
- 'Output Pose': 'Output Pose',
36
- 'Corresponding Dataset (see repository linked above)': 'Dataset',
37
- 'Simulator (see repository linked above)': 'Simulator',
38
- 'Backbone': 'Backbone',
39
- 'Metric(s) Used ': 'Metrics',
40
- 'Camera Position(s)': 'Camera',
41
- 'Language': 'Language'
42
- };
43
-
44
  function App() {
45
  const [data, setData] = useState([]);
 
 
46
  const [loading, setLoading] = useState(true);
47
  const [recomputing, setRecomputing] = useState(false);
48
  const [error, setError] = useState(null);
49
- const [selectedPoint, setSelectedPoint] = useState(null);
 
 
 
 
 
 
 
 
50
  const [hoveredIndex, setHoveredIndex] = useState(null);
51
- const [selectedIndices, setSelectedIndices] = useState(null);
52
- const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'asc' });
53
- const [colorBy, setColorBy] = useState('cluster');
54
- const [config, setConfig] = useState(null);
55
- const [weights, setWeights] = useState({});
56
- const [defaultWeights, setDefaultWeights] = useState({});
57
- const [clustering, setClustering] = useState(null);
58
 
59
- const fetchUmap = useCallback((customWeights = null) => {
60
- const isRecompute = customWeights !== null;
61
- if (isRecompute) {
62
- setRecomputing(true);
63
- } else {
64
- setLoading(true);
65
- }
66
-
67
- const options = customWeights ? {
 
 
68
  method: 'POST',
69
  headers: { 'Content-Type': 'application/json' },
70
- body: JSON.stringify({ weights: customWeights })
71
  } : {};
72
-
73
  fetch('/api/umap', options)
74
  .then(res => res.json())
75
  .then(result => {
76
  if (result.success) {
77
  setData(result.data);
78
- setConfig(result.config);
79
  setWeights(result.config.weights);
80
- if (result.config.defaultWeights) {
81
- setDefaultWeights(result.config.defaultWeights);
82
- }
83
- if (result.clustering) {
84
- setClustering(result.clustering);
85
  }
86
  } else {
87
  setError(result.error || 'Failed to load data');
@@ -97,307 +66,166 @@ function App() {
97
  }, []);
98
 
99
  useEffect(() => {
 
100
  fetchUmap();
101
  }, [fetchUmap]);
102
 
103
- const handleWeightChange = (col, value) => {
104
- const numValue = parseInt(value) || 0;
105
- setWeights(prev => ({ ...prev, [col]: Math.max(0, Math.min(20, numValue)) }));
106
- };
107
-
108
- const handleRecompute = () => {
109
- fetchUmap(weights);
110
- };
111
-
112
- const handleResetWeights = () => {
113
- setWeights(defaultWeights);
114
- };
115
-
116
- const weightsChanged = useMemo(() => {
117
- return JSON.stringify(weights) !== JSON.stringify(config?.weights);
118
- }, [weights, config]);
119
-
120
- // Get unique values for a column (for coloring)
121
- const uniqueValues = useMemo(() => {
122
- if (!data.length || colorBy === 'index') return null;
123
- const values = [...new Set(data.map(d => d.metadata[colorBy] || 'N/A'))].sort();
124
- return values;
125
- }, [data, colorBy]);
126
-
127
- // Map values to colors
128
- const colorMap = useMemo(() => {
129
- if (!uniqueValues) return null;
130
- const map = {};
131
- uniqueValues.forEach((val, i) => {
132
- map[val] = i;
133
- });
134
- return map;
135
- }, [uniqueValues]);
136
-
137
- // Sorted and filtered data for table
138
- const tableData = useMemo(() => {
139
- let filtered = selectedIndices ? data.filter((_, i) => selectedIndices.includes(i)) : data;
140
-
141
- return [...filtered].sort((a, b) => {
142
- let aVal, bVal;
143
- if (sortConfig.key === 'name') {
144
- aVal = a.name;
145
- bVal = b.name;
146
  } else {
147
- aVal = a.metadata[sortConfig.key] || '';
148
- bVal = b.metadata[sortConfig.key] || '';
149
  }
150
-
151
- if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
152
- if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
153
- return 0;
154
- });
155
- }, [data, selectedIndices, sortConfig]);
156
-
157
- const handleSort = (key) => {
158
- setSortConfig(prev => ({
159
- key,
160
- direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
161
- }));
162
- };
163
-
164
- const handlePointClick = (event) => {
165
- if (event.points && event.points.length > 0) {
166
- const pointIndex = event.points[0].pointIndex;
167
- setSelectedPoint(data[pointIndex]);
168
- }
169
- };
170
-
171
- const handlePlotHover = (event) => {
172
- if (event.points && event.points.length > 0) {
173
- setHoveredIndex(event.points[0].pointIndex);
174
- }
175
- };
176
-
177
- const handlePlotUnhover = () => {
178
- setHoveredIndex(null);
179
- };
180
-
181
- const handleSelection = (event) => {
182
- if (event && event.points && event.points.length > 0) {
183
- const indices = event.points.map(p => p.pointIndex);
184
- setSelectedIndices(indices);
185
  }
186
  };
187
 
188
- const clearSelection = () => {
189
- setSelectedIndices(null);
 
 
 
 
 
190
  };
191
 
192
  if (loading) {
193
- return <div className="App"><div className="loading">Loading UMAP projection...</div></div>;
 
 
 
 
 
 
 
194
  }
195
 
196
  if (error) {
197
- return <div className="App"><div className="error">Error: {error}</div></div>;
198
- }
199
-
200
- // Prepare plot colors
201
- let markerColors, colorbarTitle, useDiscreteColors;
202
- if (colorBy === 'cluster') {
203
- markerColors = data.map(d => CLUSTER_COLORS[d.cluster] || '#999');
204
- colorbarTitle = 'Cluster';
205
- useDiscreteColors = true;
206
- } else if (colorBy === 'index') {
207
- markerColors = data.map((_, i) => i);
208
- colorbarTitle = 'Index';
209
- useDiscreteColors = false;
210
- } else {
211
- markerColors = data.map(d => colorMap[d.metadata[colorBy] || 'N/A']);
212
- colorbarTitle = SHORT_NAMES[colorBy] || colorBy;
213
- useDiscreteColors = false;
214
  }
215
 
216
- // Highlight hovered point
217
- const markerSizes = data.map((_, i) => i === hoveredIndex ? 16 : 10);
218
- const markerOpacity = data.map((_, i) => {
219
- if (selectedIndices && !selectedIndices.includes(i)) return 0.2;
220
- return i === hoveredIndex ? 1 : 0.8;
221
- });
222
-
223
- const plotData = [{
224
- x: data.map(d => d.x),
225
- y: data.map(d => d.y),
226
- mode: 'markers',
227
- type: 'scatter',
228
- text: data.map(d => d.name),
229
- customdata: data.map((d, i) => i),
230
- hovertemplate: '<b>%{text}</b><extra></extra>',
231
- marker: {
232
- size: markerSizes,
233
- color: markerColors,
234
- colorscale: useDiscreteColors ? undefined : (colorBy === 'index' ? 'Viridis' : 'Portland'),
235
- showscale: !useDiscreteColors,
236
- opacity: markerOpacity,
237
- line: {
238
- color: data.map((_, i) => i === hoveredIndex ? '#ff0000' : 'rgba(0,0,0,0.3)'),
239
- width: data.map((_, i) => i === hoveredIndex ? 3 : 1)
240
- },
241
- colorbar: useDiscreteColors ? undefined : {
242
- title: colorbarTitle,
243
- tickvals: colorBy !== 'index' && uniqueValues ? uniqueValues.map((_, i) => i) : undefined,
244
- ticktext: colorBy !== 'index' && uniqueValues ? uniqueValues.map(v => v.length > 15 ? v.slice(0, 15) + '...' : v) : undefined
245
- }
246
- },
247
- selectedpoints: selectedIndices || undefined
248
- }];
249
-
250
- const layout = {
251
- title: 'Grasp Planner UMAP Projection',
252
- xaxis: { title: 'UMAP 1' },
253
- yaxis: { title: 'UMAP 2' },
254
- hovermode: 'closest',
255
- height: 500,
256
- dragmode: 'lasso',
257
- margin: { t: 50, b: 50, l: 50, r: 100 }
258
- };
259
 
260
  return (
261
- <div className="App">
262
- <header className="App-header">
263
- <h1>🤖 Grasp Planner UMAP Visualization</h1>
264
- </header>
265
-
266
- <div className="controls">
267
- <div className="control-group">
268
- <label>Color by:</label>
269
- <select value={colorBy} onChange={(e) => setColorBy(e.target.value)}>
270
- <option value="cluster">Cluster</option>
271
- <option value="index">Index</option>
272
- {WEIGHTED_COLUMNS.map(col => (
273
- <option key={col} value={col}>{SHORT_NAMES[col]}</option>
274
- ))}
275
- </select>
276
  </div>
277
-
278
- {selectedIndices && (
279
- <div className="selection-info">
280
- <span>{selectedIndices.length} points selected</span>
281
- <button onClick={clearSelection}>Clear Selection</button>
282
- </div>
283
- )}
284
- </div>
285
 
286
- <div className="main-content">
287
- <div className="plot-section">
288
- <Plot
289
- data={plotData}
290
- layout={layout}
291
- onClick={handlePointClick}
292
- onHover={handlePlotHover}
293
- onUnhover={handlePlotUnhover}
294
- onSelected={handleSelection}
295
- style={{ width: '100%', height: '100%' }}
296
- config={{ responsive: true, displayModeBar: true }}
 
 
 
 
 
 
 
 
 
297
  />
298
- <p className="hint">💡 Use lasso/box select to filter the table and cluster-feature grid below.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  </div>
 
300
 
301
- <div className="table-section">
302
- <div className="table-header-controls">
303
- <span className="table-title">Data Table</span>
304
- <div className="weight-controls">
305
- {weightsChanged && (
306
- <>
307
- <button className="reset-btn" onClick={handleResetWeights}>Reset</button>
308
- <button className="recompute-btn" onClick={handleRecompute} disabled={recomputing}>
309
- {recomputing ? '⏳ Computing...' : '🔄 Re-run UMAP'}
310
- </button>
311
- </>
312
- )}
313
- </div>
314
- </div>
315
- <div className="table-container">
316
- <table>
317
- <thead>
318
- <tr className="weight-row">
319
- <th className="weight-header">Weight</th>
320
- {WEIGHTED_COLUMNS.map(col => (
321
- <th key={col} className="weight-cell">
322
- <input
323
- type="number"
324
- min="0"
325
- max="20"
326
- value={weights[col] ?? 0}
327
- onChange={(e) => handleWeightChange(col, e.target.value)}
328
- className={weights[col] === 0 ? 'zero-weight' : ''}
329
- title={`Weight for ${SHORT_NAMES[col]} (0 = disabled)`}
330
- />
331
- </th>
332
- ))}
333
- </tr>
334
- <tr>
335
- <th onClick={() => handleSort('name')} className={sortConfig.key === 'name' ? 'sorted' : ''}>
336
- Name {sortConfig.key === 'name' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
337
- </th>
338
- {WEIGHTED_COLUMNS.map(col => (
339
- <th
340
- key={col}
341
- onClick={() => handleSort(col)}
342
- className={`${sortConfig.key === col ? 'sorted' : ''} ${weights[col] === 0 ? 'disabled-col' : ''}`}
343
- title={col}
344
- >
345
- {SHORT_NAMES[col]} {sortConfig.key === col && (sortConfig.direction === 'asc' ? '↑' : '↓')}
346
- </th>
347
- ))}
348
- </tr>
349
- </thead>
350
- <tbody>
351
- {tableData.map((row) => (
352
- <tr
353
- key={row.id}
354
- className={`
355
- ${hoveredIndex === row.id ? 'hovered' : ''}
356
- ${selectedPoint?.id === row.id ? 'selected' : ''}
357
- `}
358
- onMouseEnter={() => setHoveredIndex(row.id)}
359
- onMouseLeave={() => setHoveredIndex(null)}
360
- onClick={() => setSelectedPoint(row)}
361
- >
362
- <td className="name-cell" title={row.name}>{row.name}</td>
363
- {WEIGHTED_COLUMNS.map(col => (
364
- <td key={col} title={row.metadata[col] || ''} className={weights[col] === 0 ? 'disabled-col' : ''}>
365
- {row.metadata[col] || <span className="empty">-</span>}
366
- </td>
367
- ))}
368
- </tr>
369
- ))}
370
- </tbody>
371
- </table>
372
- </div>
373
  </div>
374
- </div>
375
 
376
- {clustering && (
377
- <ClusterFeatureGrid
378
  data={data}
379
- selectedIndices={selectedIndices}
380
- valueClusterMap={clustering.value_cluster_map}
381
- nClusters={clustering.n_clusters}
382
- clusterColors={CLUSTER_COLORS}
 
 
383
  />
384
- )}
385
 
386
- {selectedPoint && (
387
- <div className="detail-panel">
388
- <div className="detail-header">
389
- <h2>{selectedPoint.name}</h2>
390
- <button onClick={() => setSelectedPoint(null)}>×</button>
391
- </div>
392
-
393
- <div className="detail-content">
394
- <div className="description">
395
- <h3>Description</h3>
396
- <p>{selectedPoint.description}</p>
397
- </div>
398
- </div>
399
- </div>
400
- )}
401
  </div>
402
  );
403
  }
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import staticInsightData from './staticClusterInsight.json';
3
+ import InsightCard from './components/InsightCard';
4
+ import ClusterOverview from './components/ClusterOverview';
5
+ import ScatterPlot from './components/ScatterPlot';
6
+ import MethodTable from './components/MethodTable';
7
+ import DetailPanel from './components/DetailPanel';
8
  import './App.css';
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  function App() {
11
  const [data, setData] = useState([]);
12
+ const [weights, setWeights] = useState({});
13
+ const [colorBy, setColorBy] = useState('cluster');
14
  const [loading, setLoading] = useState(true);
15
  const [recomputing, setRecomputing] = useState(false);
16
  const [error, setError] = useState(null);
17
+ const [filterActive, setFilterActive] = useState(false);
18
+ const [filterCount, setFilterCount] = useState(null);
19
+
20
+ const [query, setQuery] = useState('');
21
+ const [querying, setQuerying] = useState(false);
22
+ const [suggestion, setSuggestion] = useState(null);
23
+ const [queryError, setQueryError] = useState(null);
24
+
25
+ const [highlightedMethods, setHighlightedMethods] = useState([]);
26
  const [hoveredIndex, setHoveredIndex] = useState(null);
27
+ const [selectedPoint, setSelectedPoint] = useState(null);
 
 
 
 
 
 
28
 
29
+ const [clusterInsight] = useState(staticInsightData.insight);
30
+ const [clusterStats, setClusterStats] = useState(staticInsightData.clusterStats);
31
+
32
+ const fetchUmap = useCallback((customWeights = null, filterMethods = null) => {
33
+ setRecomputing(true);
34
+
35
+ const body = {};
36
+ if (customWeights) body.weights = customWeights;
37
+ if (filterMethods) body.filterMethods = filterMethods;
38
+
39
+ const options = Object.keys(body).length > 0 ? {
40
  method: 'POST',
41
  headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify(body)
43
  } : {};
44
+
45
  fetch('/api/umap', options)
46
  .then(res => res.json())
47
  .then(result => {
48
  if (result.success) {
49
  setData(result.data);
 
50
  setWeights(result.config.weights);
51
+ if (result.filter) {
52
+ setFilterActive(result.filter.active);
53
+ setFilterCount(result.filter.active ? result.data.length : null);
 
 
54
  }
55
  } else {
56
  setError(result.error || 'Failed to load data');
 
66
  }, []);
67
 
68
  useEffect(() => {
69
+ setLoading(true);
70
  fetchUmap();
71
  }, [fetchUmap]);
72
 
73
+ const handleQuerySubmit = async (e) => {
74
+ e.preventDefault();
75
+ if (!query.trim() || querying) return;
76
+
77
+ setQuerying(true);
78
+ setQueryError(null);
79
+ setSuggestion(null);
80
+
81
+ try {
82
+ const res = await fetch('/api/ai-query', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify({
86
+ query: query.trim(),
87
+ currentWeights: weights,
88
+ currentColorBy: colorBy
89
+ })
90
+ });
91
+ const result = await res.json();
92
+
93
+ if (result.success) {
94
+ setSuggestion(result);
95
+ setData(result.umapData);
96
+ setWeights(result.weights);
97
+ setColorBy(result.colorBy);
98
+ setHighlightedMethods(result.highlightMethods || []);
99
+ setSelectedPoint(null);
100
+ setFilterActive(!!result.filterMethods);
101
+ setFilterCount(result.filterMethods ? result.umapData.length : null);
102
+ if (result.clusterStats) setClusterStats(result.clusterStats);
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  } else {
104
+ setQueryError(result.error || 'Unknown error');
 
105
  }
106
+ } catch (err) {
107
+ setQueryError(err.message);
108
+ } finally {
109
+ setQuerying(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  }
111
  };
112
 
113
+ const handleReset = () => {
114
+ setHighlightedMethods([]);
115
+ setSelectedPoint(null);
116
+ setSuggestion(null);
117
+ setFilterActive(false);
118
+ setFilterCount(null);
119
+ fetchUmap();
120
  };
121
 
122
  if (loading) {
123
+ return (
124
+ <div className="copilot-app">
125
+ <div className="loading-screen">
126
+ <div className="spinner" />
127
+ <p>Loading UMAP projection...</p>
128
+ </div>
129
+ </div>
130
+ );
131
  }
132
 
133
  if (error) {
134
+ return (
135
+ <div className="copilot-app">
136
+ <div className="error-screen">Error: {error}</div>
137
+ </div>
138
+ );
 
 
 
 
 
 
 
 
 
 
 
 
139
  }
140
 
141
+ const hasHighlights = highlightedMethods.length > 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
  return (
144
+ <div className="copilot-app">
145
+ <header className="copilot-header">
146
+ <div className="header-content">
147
+ <h1>Grasp Explorer</h1>
148
+ <span className="badge">AI-in-the-Loop</span>
 
 
 
 
 
 
 
 
 
 
149
  </div>
150
+ </header>
 
 
 
 
 
 
 
151
 
152
+ <section className="query-section">
153
+ <form onSubmit={handleQuerySubmit} className="query-form">
154
+ <input
155
+ type="text"
156
+ value={query}
157
+ onChange={(e) => setQuery(e.target.value)}
158
+ placeholder='e.g., "methods for cluttered scenes with multi-finger grippers"'
159
+ disabled={querying}
160
+ className="query-input"
161
+ />
162
+ <button type="submit" disabled={querying || !query.trim()} className="query-btn">
163
+ {querying ? 'Thinking...' : 'Ask'}
164
+ </button>
165
+ </form>
166
+ {queryError && <div className="query-error">{queryError}</div>}
167
+ {suggestion && (
168
+ <InsightCard
169
+ suggestion={suggestion}
170
+ weights={weights}
171
+ onClose={() => setSuggestion(null)}
172
  />
173
+ )}
174
+ </section>
175
+
176
+ {(recomputing || filterActive || hasHighlights) && (
177
+ <div className="status-bar">
178
+ {recomputing && <span className="status-computing">Recomputing UMAP...</span>}
179
+ {!recomputing && filterActive && (
180
+ <span className="status-filter">Filtered: {filterCount} methods</span>
181
+ )}
182
+ {!recomputing && hasHighlights && (
183
+ <span className="status-highlights">
184
+ {highlightedMethods.length} best matches highlighted
185
+ </span>
186
+ )}
187
+ {!recomputing && (filterActive || hasHighlights) && (
188
+ <button className="reset-btn" onClick={handleReset}>Reset to all methods</button>
189
+ )}
190
  </div>
191
+ )}
192
 
193
+ <div className="main-content">
194
+ <div className="scatter-panel">
195
+ <ScatterPlot
196
+ data={data}
197
+ colorBy={colorBy}
198
+ highlightedMethods={highlightedMethods}
199
+ hoveredIndex={hoveredIndex}
200
+ onPointClick={setSelectedPoint}
201
+ onHover={setHoveredIndex}
202
+ onUnhover={() => setHoveredIndex(null)}
203
+ />
204
+ {!suggestion && (
205
+ <ClusterOverview
206
+ insight={clusterInsight}
207
+ loading={false}
208
+ stats={clusterStats}
209
+ />
210
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  </div>
 
212
 
213
+ <MethodTable
 
214
  data={data}
215
+ highlightedMethods={highlightedMethods}
216
+ selectedPoint={selectedPoint}
217
+ hoveredIndex={hoveredIndex}
218
+ onSelect={setSelectedPoint}
219
+ onHover={setHoveredIndex}
220
+ onUnhover={() => setHoveredIndex(null)}
221
  />
222
+ </div>
223
 
224
+ <DetailPanel point={selectedPoint} onClose={() => setSelectedPoint(null)} />
225
+
226
+ <footer className="copilot-footer">
227
+ <span>Grasp Planner Explorer &middot; COMPARE Project &middot; WPI</span>
228
+ </footer>
 
 
 
 
 
 
 
 
 
 
229
  </div>
230
  );
231
  }
frontend/src/ClusterFeatureGrid.css DELETED
@@ -1,230 +0,0 @@
1
- .cfg-container {
2
- background: white;
3
- border-radius: 8px;
4
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
5
- padding: 1rem;
6
- margin: 1rem;
7
- }
8
-
9
- .cfg-header {
10
- display: flex;
11
- justify-content: space-between;
12
- align-items: center;
13
- margin-bottom: 0.75rem;
14
- flex-wrap: wrap;
15
- gap: 0.5rem;
16
- }
17
-
18
- .cfg-header h3 {
19
- margin: 0;
20
- font-size: 1.1rem;
21
- color: #333;
22
- }
23
-
24
- .cfg-subtitle {
25
- font-weight: 400;
26
- color: #888;
27
- font-size: 0.9rem;
28
- }
29
-
30
- .cfg-stats {
31
- display: flex;
32
- gap: 0.5rem;
33
- align-items: center;
34
- font-size: 0.85rem;
35
- }
36
-
37
- .cfg-stat-match {
38
- font-weight: 600;
39
- color: #28a745;
40
- background: #d4edda;
41
- padding: 0.2rem 0.6rem;
42
- border-radius: 12px;
43
- }
44
-
45
- .cfg-stat-detail {
46
- color: #888;
47
- }
48
-
49
- /* Legend */
50
- .cfg-legend {
51
- display: flex;
52
- flex-wrap: wrap;
53
- gap: 0.6rem;
54
- margin-bottom: 0.75rem;
55
- padding: 0.5rem;
56
- background: #f8f9fa;
57
- border-radius: 6px;
58
- }
59
-
60
- .cfg-legend-item {
61
- display: flex;
62
- align-items: center;
63
- gap: 0.25rem;
64
- font-size: 0.75rem;
65
- color: #555;
66
- font-weight: 500;
67
- }
68
-
69
- .cfg-legend-swatch {
70
- width: 14px;
71
- height: 14px;
72
- border-radius: 3px;
73
- border: 1px solid rgba(0,0,0,0.15);
74
- flex-shrink: 0;
75
- }
76
-
77
- .cfg-legend-missing {
78
- background: #f0f0f0 !important;
79
- }
80
-
81
- /* Grid scroll wrapper */
82
- .cfg-scroll-wrapper {
83
- overflow-x: auto;
84
- overflow-y: auto;
85
- max-height: 70vh;
86
- border: 1px solid #e0e0e0;
87
- border-radius: 6px;
88
- }
89
-
90
- /* CSS Grid */
91
- .cfg-grid {
92
- display: grid;
93
- gap: 0;
94
- min-width: fit-content;
95
- }
96
-
97
- /* Corner cell */
98
- .cfg-corner {
99
- position: sticky;
100
- left: 0;
101
- z-index: 3;
102
- background: #f8f9fa;
103
- font-weight: 600;
104
- font-size: 0.75rem;
105
- color: #555;
106
- padding: 0.4rem 0.5rem;
107
- display: flex;
108
- align-items: center;
109
- border-bottom: 2px solid #dee2e6;
110
- }
111
-
112
- /* Cluster label cells */
113
- .cfg-cluster-label {
114
- text-align: center;
115
- font-weight: 700;
116
- font-size: 0.75rem;
117
- padding: 0.35rem 0.2rem;
118
- border-bottom: 2px solid #dee2e6;
119
- border-right: 2px solid rgba(0,0,0,0.2);
120
- white-space: nowrap;
121
- }
122
-
123
- /* Feature row labels (y-axis) */
124
- .cfg-row-label {
125
- position: sticky;
126
- left: 0;
127
- z-index: 2;
128
- background: #f8f9fa;
129
- font-weight: 600;
130
- font-size: 0.78rem;
131
- color: #444;
132
- padding: 0.3rem 0.5rem;
133
- display: flex;
134
- align-items: center;
135
- white-space: nowrap;
136
- border-right: 2px solid #dee2e6;
137
- border-bottom: 1px solid #eee;
138
- min-width: 100px;
139
- }
140
-
141
- .cfg-feature-header {
142
- font-weight: 700;
143
- border-bottom: 2px solid #dee2e6;
144
- color: #333;
145
- }
146
-
147
- /* Planner name cells (x-axis headers) */
148
- .cfg-planner-name {
149
- font-size: 0.6rem;
150
- color: #333;
151
- padding: 0.25rem 0.15rem;
152
- writing-mode: vertical-lr;
153
- text-orientation: mixed;
154
- transform: rotate(180deg);
155
- text-align: left;
156
- white-space: nowrap;
157
- overflow: hidden;
158
- text-overflow: ellipsis;
159
- max-height: 110px;
160
- border-bottom: 2px solid #dee2e6;
161
- border-right: 1px solid rgba(0,0,0,0.05);
162
- font-weight: 500;
163
- min-width: 22px;
164
- }
165
-
166
- /* Data cells */
167
- .cfg-cell {
168
- padding: 0.2rem 0.15rem;
169
- border-right: 1px solid rgba(255,255,255,0.6);
170
- border-bottom: 1px solid rgba(0,0,0,0.06);
171
- display: flex;
172
- align-items: center;
173
- justify-content: center;
174
- min-height: 28px;
175
- min-width: 22px;
176
- transition: opacity 0.15s;
177
- cursor: default;
178
- position: relative;
179
- }
180
-
181
- .cfg-cell:hover {
182
- opacity: 0.8;
183
- outline: 2px solid #333;
184
- outline-offset: -1px;
185
- z-index: 1;
186
- }
187
-
188
- .cfg-cell-text {
189
- font-size: 0.55rem;
190
- color: rgba(0,0,0,0.6);
191
- overflow: hidden;
192
- text-overflow: ellipsis;
193
- white-space: nowrap;
194
- max-width: 100%;
195
- text-align: center;
196
- line-height: 1.1;
197
- }
198
-
199
- /* Cross-cluster indicator */
200
- .cfg-cross-cluster {
201
- box-shadow: inset 0 0 0 1.5px rgba(0,0,0,0.25);
202
- }
203
-
204
- /* Cluster boundary */
205
- .cfg-boundary {
206
- border-left: 3px solid #333 !important;
207
- }
208
-
209
- /* Hint text */
210
- .cfg-hint {
211
- text-align: center;
212
- color: #888;
213
- font-size: 0.8rem;
214
- margin: 0.75rem 0 0 0;
215
- line-height: 1.4;
216
- }
217
-
218
- /* Empty state */
219
- .cfg-empty {
220
- text-align: center;
221
- color: #aaa;
222
- padding: 2rem;
223
- font-style: italic;
224
- }
225
-
226
- @media (max-width: 1200px) {
227
- .cfg-cell { min-width: 18px; min-height: 24px; }
228
- .cfg-planner-name { font-size: 0.55rem; min-width: 18px; }
229
- .cfg-cell-text { font-size: 0.5rem; }
230
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/ClusterFeatureGrid.js DELETED
@@ -1,217 +0,0 @@
1
- import React, { useMemo } from 'react';
2
- import './ClusterFeatureGrid.css';
3
-
4
- const SHORT_NAMES = {
5
- 'Planning Method': 'Planning',
6
- 'Training Data': 'Training',
7
- 'End-effector Hardware': 'Effector',
8
- 'Object Configuration': 'ObjConfig',
9
- 'Input Data': 'Input',
10
- 'Output Pose': 'Output',
11
- 'Corresponding Dataset (see repository linked above)': 'Dataset',
12
- 'Simulator (see repository linked above)': 'Simulator',
13
- 'Backbone': 'Backbone',
14
- 'Metric(s) Used ': 'Metrics',
15
- 'Camera Position(s)': 'Camera',
16
- 'Language': 'Language'
17
- };
18
-
19
- const WEIGHTED_COLUMNS = [
20
- 'Planning Method',
21
- 'Training Data',
22
- 'End-effector Hardware',
23
- 'Object Configuration',
24
- 'Input Data',
25
- 'Output Pose',
26
- 'Corresponding Dataset (see repository linked above)',
27
- 'Simulator (see repository linked above)',
28
- 'Backbone',
29
- 'Metric(s) Used ',
30
- 'Camera Position(s)',
31
- 'Language'
32
- ];
33
-
34
- function ClusterFeatureGrid({ data, selectedIndices, valueClusterMap, nClusters, clusterColors }) {
35
- const CLUSTER_COLORS = clusterColors;
36
- const planners = useMemo(() => {
37
- let subset = selectedIndices
38
- ? data.filter((_, i) => selectedIndices.includes(i))
39
- : data;
40
- return [...subset].sort((a, b) => {
41
- if (a.cluster !== b.cluster) return a.cluster - b.cluster;
42
- return a.name.localeCompare(b.name);
43
- });
44
- }, [data, selectedIndices]);
45
-
46
- const clusterBoundaries = useMemo(() => {
47
- const boundaries = [];
48
- for (let i = 1; i < planners.length; i++) {
49
- if (planners[i].cluster !== planners[i - 1].cluster) {
50
- boundaries.push(i);
51
- }
52
- }
53
- return boundaries;
54
- }, [planners]);
55
-
56
- const clusterLabels = useMemo(() => {
57
- if (planners.length === 0) return [];
58
- const labels = [];
59
- let start = 0;
60
- let currentCluster = planners[0].cluster;
61
- for (let i = 1; i <= planners.length; i++) {
62
- if (i === planners.length || planners[i].cluster !== currentCluster) {
63
- labels.push({ cluster: currentCluster, start, end: i - 1, count: i - start });
64
- if (i < planners.length) {
65
- start = i;
66
- currentCluster = planners[i].cluster;
67
- }
68
- }
69
- }
70
- return labels;
71
- }, [planners]);
72
-
73
- const getCellCluster = (planner, col) => {
74
- const rawVal = planner.metadata[col] || '';
75
- const firstVal = rawVal.split(',')[0].trim();
76
- if (!firstVal || !valueClusterMap || !valueClusterMap[col]) return -1;
77
- const clust = valueClusterMap[col][firstVal];
78
- return clust !== undefined ? clust : -1;
79
- };
80
-
81
- const stats = useMemo(() => {
82
- let match = 0, differ = 0;
83
- planners.forEach(p => {
84
- WEIGHTED_COLUMNS.forEach(col => {
85
- const cellC = getCellCluster(p, col);
86
- if (cellC === -1) return;
87
- if (cellC === p.cluster) match++;
88
- else differ++;
89
- });
90
- });
91
- return { match, differ, total: match + differ };
92
- // eslint-disable-next-line react-hooks/exhaustive-deps
93
- }, [planners, valueClusterMap]);
94
-
95
- if (planners.length === 0) {
96
- return (
97
- <div className="cfg-container">
98
- <div className="cfg-empty">No planners to display. Select points on the scatter plot above.</div>
99
- </div>
100
- );
101
- }
102
-
103
- return (
104
- <div className="cfg-container">
105
- <div className="cfg-header">
106
- <h3>
107
- Cluster–Feature Grid
108
- <span className="cfg-subtitle">
109
- {selectedIndices
110
- ? ` — ${planners.length} selected planners`
111
- : ` — All ${planners.length} planners`}
112
- </span>
113
- </h3>
114
- {stats.total > 0 && (
115
- <div className="cfg-stats">
116
- <span className="cfg-stat-match">{Math.round(100 * stats.match / stats.total)}% consistent</span>
117
- <span className="cfg-stat-detail">({stats.match} match, {stats.differ} cross-cluster)</span>
118
- </div>
119
- )}
120
- </div>
121
-
122
- <div className="cfg-legend">
123
- {Array.from({ length: nClusters || 11 }, (_, i) => (
124
- <span key={i} className="cfg-legend-item">
125
- <span className="cfg-legend-swatch" style={{ background: CLUSTER_COLORS[i] }} />
126
- C{i}
127
- </span>
128
- ))}
129
- <span className="cfg-legend-item">
130
- <span className="cfg-legend-swatch cfg-legend-missing" />
131
- N/A
132
- </span>
133
- </div>
134
-
135
- <div className="cfg-scroll-wrapper">
136
- <div className="cfg-grid" style={{ gridTemplateColumns: `120px repeat(${planners.length}, 1fr)` }}>
137
-
138
- {/* Cluster label header row */}
139
- <div className="cfg-corner">Cluster</div>
140
- {planners.map((p, i) => {
141
- const isFirstInCluster = i === 0 || planners[i - 1].cluster !== p.cluster;
142
- const label = clusterLabels.find(l => l.cluster === p.cluster);
143
- if (isFirstInCluster && label) {
144
- return (
145
- <div
146
- key={`clabel-${i}`}
147
- className="cfg-cluster-label"
148
- style={{
149
- gridColumn: `${i + 2} / span ${label.count}`,
150
- background: CLUSTER_COLORS[p.cluster],
151
- color: '#333',
152
- }}
153
- >
154
- C{p.cluster} ({label.count})
155
- </div>
156
- );
157
- }
158
- return null;
159
- }).filter(Boolean)}
160
-
161
- {/* Planner name header row */}
162
- <div className="cfg-row-label cfg-feature-header">Planner</div>
163
- {planners.map((p, i) => {
164
- const isBoundary = clusterBoundaries.includes(i);
165
- return (
166
- <div
167
- key={`name-${i}`}
168
- className={`cfg-planner-name ${isBoundary ? 'cfg-boundary' : ''}`}
169
- title={p.name}
170
- >
171
- {p.name.replace('🤖 ', '').substring(0, 16)}
172
- </div>
173
- );
174
- })}
175
-
176
- {/* Feature rows */}
177
- {WEIGHTED_COLUMNS.map(col => (
178
- <React.Fragment key={col}>
179
- <div className="cfg-row-label" title={col}>
180
- {SHORT_NAMES[col]}
181
- </div>
182
- {planners.map((p, i) => {
183
- const cellCluster = getCellCluster(p, col);
184
- const rawVal = p.metadata[col] || '';
185
- const firstVal = rawVal.split(',')[0].trim();
186
- const displayVal = firstVal.substring(0, 14);
187
- const ownCluster = p.cluster;
188
- const isMatch = cellCluster === ownCluster;
189
- const isBoundary = clusterBoundaries.includes(i);
190
- const bgColor = cellCluster >= 0 ? CLUSTER_COLORS[cellCluster] : '#f0f0f0';
191
-
192
- return (
193
- <div
194
- key={`${col}-${i}`}
195
- className={`cfg-cell ${isBoundary ? 'cfg-boundary' : ''} ${!isMatch && cellCluster >= 0 ? 'cfg-cross-cluster' : ''}`}
196
- style={{ background: bgColor }}
197
- title={`${p.name}\n${SHORT_NAMES[col]}: ${rawVal}\nOwn cluster: C${ownCluster}\nValue owned by: C${cellCluster}${isMatch ? ' (MATCH)' : ' (differs)'}`}
198
- >
199
- <span className="cfg-cell-text">{displayVal}</span>
200
- </div>
201
- );
202
- })}
203
- </React.Fragment>
204
- ))}
205
- </div>
206
- </div>
207
-
208
- <p className="cfg-hint">
209
- Each cell is colored by which cluster <strong>owns</strong> that feature value.
210
- Matching own-cluster color = consistent. Different color = cross-cluster trait.
211
- {selectedIndices && ' Select different points on the scatter to update this grid.'}
212
- </p>
213
- </div>
214
- );
215
- }
216
-
217
- export default ClusterFeatureGrid;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/ClusterOverview.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import InsightBullets from './InsightBullets';
3
+ import { CLUSTER_COLORS } from '../constants';
4
+
5
+ export default function ClusterOverview({ insight, loading, stats }) {
6
+ if (!insight && !loading && !stats) return null;
7
+
8
+ return (
9
+ <div className="cluster-insight-card">
10
+ <div className="cluster-insight-header">
11
+ <span className="cluster-insight-icon">AI</span>
12
+ <span className="cluster-insight-title">Cluster Overview</span>
13
+ </div>
14
+ <div className="cluster-insight-body">
15
+ {loading ? (
16
+ <p className="cluster-insight-loading">Analyzing clusters...</p>
17
+ ) : (
18
+ <InsightBullets text={insight} />
19
+ )}
20
+ </div>
21
+ {stats && (
22
+ <div className="cluster-legend">
23
+ <div className="cluster-legend-title">Cluster Legend</div>
24
+ <div className="cluster-legend-grid">
25
+ {stats.map(cs => (
26
+ <div key={cs.id} className="cluster-legend-item">
27
+ <div className="cluster-legend-header">
28
+ <span className="cluster-color-dot" style={{ background: CLUSTER_COLORS[cs.id] || '#999' }} />
29
+ <span className="cluster-legend-name">C{cs.id}</span>
30
+ <span className="cluster-legend-count">{cs.size} methods</span>
31
+ </div>
32
+ <div className="cluster-legend-label">{cs.label}</div>
33
+ <div className="cluster-legend-methods">
34
+ {cs.methods.slice(0, 4).join(', ')}
35
+ {cs.methods.length > 4 ? `, +${cs.methods.length - 4} more` : ''}
36
+ </div>
37
+ </div>
38
+ ))}
39
+ </div>
40
+ </div>
41
+ )}
42
+ </div>
43
+ );
44
+ }
frontend/src/components/DetailPanel.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { SHORT_NAMES } from '../constants';
3
+
4
+ const HIDDEN_KEYS = ['Name', 'Description', 'Combined_Description', 'Link(s)', 'Citation'];
5
+
6
+ export default function DetailPanel({ point, onClose }) {
7
+ if (!point) return null;
8
+
9
+ return (
10
+ <div className="detail-panel">
11
+ <div className="detail-panel-header">
12
+ <h3>{point.name}</h3>
13
+ <button onClick={onClose}>&times;</button>
14
+ </div>
15
+ <div className="detail-panel-body">
16
+ <p className="detail-description">{point.description}</p>
17
+ <div className="detail-metadata">
18
+ {Object.entries(point.metadata || {}).map(([key, val]) => {
19
+ if (!val || HIDDEN_KEYS.includes(key)) return null;
20
+ return (
21
+ <div key={key} className="metadata-row">
22
+ <span className="metadata-key">{SHORT_NAMES[key] || key}:</span>
23
+ <span className="metadata-val">{val}</span>
24
+ </div>
25
+ );
26
+ })}
27
+ </div>
28
+ </div>
29
+ </div>
30
+ );
31
+ }
frontend/src/components/InsightBullets.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function InsightBullets({ text }) {
4
+ if (!text) return null;
5
+ const bullets = text.split('\n').filter(l => l.trim().startsWith('- '));
6
+ if (bullets.length > 0) {
7
+ return (
8
+ <ul className="insight-bullets">
9
+ {bullets.map((line, i) => (
10
+ <li key={i}>{line.replace(/^-\s*/, '')}</li>
11
+ ))}
12
+ </ul>
13
+ );
14
+ }
15
+ return <p>{text}</p>;
16
+ }
frontend/src/components/InsightCard.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import InsightBullets from './InsightBullets';
3
+ import { SHORT_NAMES } from '../constants';
4
+
5
+ export default function InsightCard({ suggestion, weights, onClose }) {
6
+ const weightDiffs = Object.entries(suggestion.weights)
7
+ .filter(([col, val]) => val !== (weights[col] ?? 0))
8
+ .map(([col, val]) => ({
9
+ col,
10
+ short: SHORT_NAMES[col] || col,
11
+ from: weights[col] ?? 0,
12
+ to: val
13
+ }));
14
+
15
+ return (
16
+ <div className="insight-card">
17
+ <div className="insight-card-header">
18
+ <span className="insight-icon">AI</span>
19
+ <span className="insight-title">Copilot Insight</span>
20
+ <button className="insight-close" onClick={onClose}>&times;</button>
21
+ </div>
22
+ <div className="insight-body">
23
+ <InsightBullets text={suggestion.insight} />
24
+ </div>
25
+ <div className="insight-actions-summary">
26
+ {suggestion.filterMethods && (
27
+ <span className="action-chip filter-chip">
28
+ Filtered to {suggestion.filterMethods.length} methods
29
+ </span>
30
+ )}
31
+ {(suggestion.highlightMethods || []).length > 0 && (
32
+ <span className="action-chip highlight-chip">
33
+ {suggestion.highlightMethods.length} best matches
34
+ </span>
35
+ )}
36
+ {weightDiffs.length > 0 && (
37
+ <span className="action-chip weight-change-chip">
38
+ {weightDiffs.length} weight{weightDiffs.length > 1 ? 's' : ''} adjusted
39
+ </span>
40
+ )}
41
+ </div>
42
+ {weightDiffs.length > 0 && (
43
+ <div className="insight-weight-details">
44
+ {weightDiffs.map(({ col, short, from, to }) => (
45
+ <span key={col} className="weight-chip">
46
+ {short}: {from} &rarr; <strong>{to}</strong>
47
+ </span>
48
+ ))}
49
+ </div>
50
+ )}
51
+ {(suggestion.highlightMethods || []).length > 0 && (
52
+ <div className="insight-matches">
53
+ <span className="matches-label">Best matches:</span>
54
+ {suggestion.highlightMethods.join(', ')}
55
+ </div>
56
+ )}
57
+ </div>
58
+ );
59
+ }
frontend/src/components/MethodTable.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { SHORT_NAMES, TABLE_COLUMNS } from '../constants';
3
+
4
+ export default function MethodTable({
5
+ data,
6
+ highlightedMethods,
7
+ selectedPoint,
8
+ hoveredIndex,
9
+ onSelect,
10
+ onHover,
11
+ onUnhover,
12
+ }) {
13
+ const hasHighlights = highlightedMethods.length > 0;
14
+
15
+ const tableData = [...data].sort((a, b) => {
16
+ if (hasHighlights) {
17
+ const aHL = highlightedMethods.includes(a.name) ? 0 : 1;
18
+ const bHL = highlightedMethods.includes(b.name) ? 0 : 1;
19
+ if (aHL !== bHL) return aHL - bHL;
20
+ }
21
+ return a.name.localeCompare(b.name);
22
+ });
23
+
24
+ return (
25
+ <div className="table-panel">
26
+ <div className="table-panel-header">
27
+ <span>{data.length} Methods</span>
28
+ {hasHighlights && (
29
+ <span className="hl-indicator">{highlightedMethods.length} highlighted</span>
30
+ )}
31
+ </div>
32
+ <div className="table-scroll">
33
+ <table className="data-table">
34
+ <thead>
35
+ <tr>
36
+ <th className="sticky-col">Name</th>
37
+ {TABLE_COLUMNS.map(col => (
38
+ <th key={col}>{SHORT_NAMES[col] || col}</th>
39
+ ))}
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ {tableData.map(d => (
44
+ <tr
45
+ key={d.id}
46
+ className={[
47
+ highlightedMethods.includes(d.name) ? 'row-hl' : '',
48
+ selectedPoint?.id === d.id ? 'row-sel' : '',
49
+ hoveredIndex === d.id ? 'row-hov' : ''
50
+ ].join(' ')}
51
+ onClick={() => onSelect(selectedPoint?.id === d.id ? null : d)}
52
+ onMouseEnter={() => onHover(d.id)}
53
+ onMouseLeave={onUnhover}
54
+ >
55
+ <td className="sticky-col name-cell">
56
+ {highlightedMethods.includes(d.name) && <span className="hl-dot" />}
57
+ {d.name}
58
+ </td>
59
+ {TABLE_COLUMNS.map(col => (
60
+ <td key={col} title={d.metadata[col] || ''}>
61
+ {d.metadata[col] || '-'}
62
+ </td>
63
+ ))}
64
+ </tr>
65
+ ))}
66
+ </tbody>
67
+ </table>
68
+ </div>
69
+ </div>
70
+ );
71
+ }
frontend/src/components/ScatterPlot.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo } from 'react';
2
+ import Plot from 'react-plotly.js';
3
+ import { smartSplit } from '../utils';
4
+ import { CLUSTER_COLORS, SHORT_NAMES } from '../constants';
5
+
6
+ export default function ScatterPlot({
7
+ data,
8
+ colorBy,
9
+ highlightedMethods,
10
+ hoveredIndex,
11
+ onPointClick,
12
+ onHover,
13
+ onUnhover,
14
+ }) {
15
+ const hasHighlights = highlightedMethods.length > 0;
16
+
17
+ const uniqueValues = useMemo(() => {
18
+ if (!data.length || colorBy === 'index' || colorBy === 'cluster') return null;
19
+ const valSet = new Set();
20
+ data.forEach(d => {
21
+ const parts = smartSplit(d.metadata[colorBy] || '');
22
+ if (parts.length > 0) parts.forEach(p => valSet.add(p));
23
+ else valSet.add('N/A');
24
+ });
25
+ return [...valSet].sort();
26
+ }, [data, colorBy]);
27
+
28
+ const colorMap = useMemo(() => {
29
+ if (!uniqueValues) return null;
30
+ const map = {};
31
+ uniqueValues.forEach((val, i) => { map[val] = i; });
32
+ return map;
33
+ }, [uniqueValues]);
34
+
35
+ let markerColors, useDiscreteColors;
36
+ if (colorBy === 'cluster') {
37
+ markerColors = data.map(d => CLUSTER_COLORS[d.cluster] || '#999');
38
+ useDiscreteColors = true;
39
+ } else if (colorBy === 'index') {
40
+ markerColors = data.map((_, i) => i);
41
+ useDiscreteColors = false;
42
+ } else {
43
+ markerColors = data.map(d => {
44
+ const parts = smartSplit(d.metadata[colorBy] || '');
45
+ const primaryVal = parts.length > 0 ? parts[0] : 'N/A';
46
+ return colorMap[primaryVal] ?? colorMap['N/A'] ?? 0;
47
+ });
48
+ useDiscreteColors = false;
49
+ }
50
+
51
+ const markerSizes = data.map((d, i) => {
52
+ if (i === hoveredIndex) return 20;
53
+ if (hasHighlights && highlightedMethods.includes(d.name)) return 16;
54
+ return 10;
55
+ });
56
+
57
+ const markerOpacity = data.map((d, i) => {
58
+ if (i === hoveredIndex) return 1;
59
+ if (hasHighlights && !highlightedMethods.includes(d.name)) return 0.35;
60
+ return 0.9;
61
+ });
62
+
63
+ const plotData = [{
64
+ x: data.map(d => d.x),
65
+ y: data.map(d => d.y),
66
+ mode: 'markers+text',
67
+ type: 'scatter',
68
+ text: data.map(d => {
69
+ if (hasHighlights && highlightedMethods.includes(d.name)) {
70
+ return d.name.length > 22 ? d.name.slice(0, 20) + '...' : d.name;
71
+ }
72
+ return '';
73
+ }),
74
+ textposition: 'top center',
75
+ textfont: { size: 9, color: '#333' },
76
+ hovertemplate: '<b>%{customdata}</b><extra></extra>',
77
+ customdata: data.map(d => d.name),
78
+ marker: {
79
+ size: markerSizes,
80
+ color: markerColors,
81
+ colorscale: useDiscreteColors ? undefined : 'Portland',
82
+ showscale: !useDiscreteColors,
83
+ opacity: markerOpacity,
84
+ line: {
85
+ color: data.map((d, i) => {
86
+ if (i === hoveredIndex) return '#ff0000';
87
+ if (hasHighlights && highlightedMethods.includes(d.name)) return '#667eea';
88
+ return 'rgba(0,0,0,0.15)';
89
+ }),
90
+ width: data.map((d, i) => {
91
+ if (i === hoveredIndex) return 3;
92
+ if (hasHighlights && highlightedMethods.includes(d.name)) return 3;
93
+ return 0.5;
94
+ })
95
+ },
96
+ colorbar: useDiscreteColors ? undefined : {
97
+ title: SHORT_NAMES[colorBy] || colorBy,
98
+ tickvals: uniqueValues ? uniqueValues.map((_, i) => i) : undefined,
99
+ ticktext: uniqueValues ? uniqueValues.map(v => v.length > 15 ? v.slice(0, 15) + '...' : v) : undefined
100
+ }
101
+ },
102
+ showlegend: false
103
+ }];
104
+
105
+ const layout = {
106
+ xaxis: { title: 'UMAP 1', zeroline: false },
107
+ yaxis: { title: 'UMAP 2', zeroline: false },
108
+ hovermode: 'closest',
109
+ height: 480,
110
+ margin: { t: 10, b: 40, l: 40, r: 30 },
111
+ paper_bgcolor: 'transparent',
112
+ plot_bgcolor: '#fafafa'
113
+ };
114
+
115
+ return (
116
+ <Plot
117
+ data={plotData}
118
+ layout={layout}
119
+ onClick={(event) => {
120
+ if (event.points && event.points.length > 0) {
121
+ onPointClick(data[event.points[0].pointIndex]);
122
+ }
123
+ }}
124
+ onHover={(event) => {
125
+ if (event.points && event.points.length > 0) {
126
+ onHover(event.points[0].pointIndex);
127
+ }
128
+ }}
129
+ onUnhover={onUnhover}
130
+ style={{ width: '100%' }}
131
+ config={{ responsive: true, displayModeBar: true }}
132
+ useResizeHandler={true}
133
+ />
134
+ );
135
+ }
frontend/src/constants.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const CLUSTER_COLORS = [
2
+ '#8dd3c7', '#ffffb3', '#bebada', '#fb8072', '#80b1d3',
3
+ '#fdb462', '#b3de69', '#fccde5', '#d9d9d9', '#bc80bd', '#ccebc5'
4
+ ];
5
+
6
+ export const SHORT_NAMES = {
7
+ 'Planning Method': 'Planning Method',
8
+ 'Training Data': 'Training Data',
9
+ 'End-effector Hardware': 'End-effector',
10
+ 'Object Configuration': 'Object Config',
11
+ 'Input Data': 'Input Data',
12
+ 'Output Pose': 'Output Pose',
13
+ 'Corresponding Dataset (see repository linked above)': 'Dataset',
14
+ 'Simulator (see repository linked above)': 'Simulator',
15
+ 'Backbone': 'Backbone',
16
+ 'Metric(s) Used ': 'Metrics',
17
+ 'Camera Position(s)': 'Camera',
18
+ 'Language': 'Language',
19
+ 'Description': 'Description'
20
+ };
21
+
22
+ export const TABLE_COLUMNS = [
23
+ 'Planning Method',
24
+ 'End-effector Hardware',
25
+ 'Object Configuration',
26
+ 'Input Data',
27
+ 'Output Pose',
28
+ 'Learning Paradigm',
29
+ 'Scene Difficulty',
30
+ 'Gripper Type',
31
+ ];
frontend/src/index.js CHANGED
@@ -3,4 +3,8 @@ import ReactDOM from 'react-dom/client';
3
  import App from './App';
4
 
5
  const root = ReactDOM.createRoot(document.getElementById('root'));
6
- root.render(<App />);
 
 
 
 
 
3
  import App from './App';
4
 
5
  const root = ReactDOM.createRoot(document.getElementById('root'));
6
+ root.render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
frontend/src/staticClusterInsight.json ADDED
@@ -0,0 +1,726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "insight": "- Cluster 0 highlights methods that heavily rely on 3D point cloud data and PointNet++ architectures, such as 3DAPNet and GCNGrasp, which are designed for singulated and structured object configurations. This cluster emphasizes the importance of 3D geometric understanding and sampling-based planning in grasp generation.\n- Cluster 1 groups methods like Dex-Net 1.0 and GraspSAM, which focus on 2D grasp rectangles and direct regression from depth images or point clouds. These methods are primarily trained in simulation and target singulated objects, reflecting a preference for simplicity and efficiency in grasp planning.\n- Cluster 3 includes a diverse set of methods, such as Equivariant Volumetric Grasping and GPD, that use point cloud inputs and sampling-based planning for complex object configurations like piled and cluttered scenes. This cluster underscores the need for robust 3D perception and sampling techniques to handle challenging environments.\n- Cluster 9 is dominated by reinforcement learning (RL) methods, including GA-DDPG and RobustDexGrasp, which use multi-finger grippers and multi-view camera setups. These methods aim to learn grasp policies through trial-and-error, highlighting the role of RL in achieving dexterous manipulation and robust grasp planning.\n- Cluster 10 focuses on methods like DDGC and Multi-FinGAN, which use three-finger grippers and analytical or sampling-based planning for various object configurations, including singulated and structured scenes. This cluster emphasizes the importance of advanced gripper designs and precise grasp planning algorithms for complex tasks.\n- Cluster 7 stands out with methods like 7DGCG and ShapeGrasp, which do not require training and use overhead cameras to generate 7-DoF grasp poses. These methods highlight the potential of training-less approaches and the importance of efficient grasp evaluation and sampling for cluttered and singulated objects.",
3
+ "clusterStats": [
4
+ {
5
+ "id": 0,
6
+ "label": "Sampling / Two-finger / Singulated",
7
+ "methods": [
8
+ "3DAPNet",
9
+ "6-DoF GraspNet",
10
+ "CAPGrasp",
11
+ "Contact-GraspNet",
12
+ "Deep Dexterous Grasping (DDG)",
13
+ "EquiGraspFlow",
14
+ "GCNGrasp"
15
+ ],
16
+ "size": 7,
17
+ "topAttributes": {
18
+ "Effector": [
19
+ {
20
+ "count": 6,
21
+ "value": "Two-finger"
22
+ },
23
+ {
24
+ "count": 1,
25
+ "value": "Multi-finger"
26
+ }
27
+ ],
28
+ "Input": [
29
+ {
30
+ "count": 5,
31
+ "value": "Point cloud"
32
+ },
33
+ {
34
+ "count": 2,
35
+ "value": "Depth image"
36
+ },
37
+ {
38
+ "count": 1,
39
+ "value": "RGBD image"
40
+ }
41
+ ],
42
+ "ObjConfig": [
43
+ {
44
+ "count": 5,
45
+ "value": "Singulated"
46
+ },
47
+ {
48
+ "count": 2,
49
+ "value": "Structured"
50
+ }
51
+ ],
52
+ "Plan": [
53
+ {
54
+ "count": 5,
55
+ "value": "Sampling"
56
+ },
57
+ {
58
+ "count": 1,
59
+ "value": "Generative"
60
+ },
61
+ {
62
+ "count": 1,
63
+ "value": "Direct regression"
64
+ }
65
+ ],
66
+ "Train": [
67
+ {
68
+ "count": 7,
69
+ "value": "Sim"
70
+ },
71
+ {
72
+ "count": 1,
73
+ "value": "Real"
74
+ }
75
+ ]
76
+ }
77
+ },
78
+ {
79
+ "id": 1,
80
+ "label": "Direct regression / Two-finger / Singulated",
81
+ "methods": [
82
+ "Dex-Net 1.0 (MV-CNNs)",
83
+ "Dex-Net 2.0 (GQ-CNN)",
84
+ "Dex-Net 3.0",
85
+ "ggcnn_plus",
86
+ "GraspSAM",
87
+ "UniGrasp"
88
+ ],
89
+ "size": 6,
90
+ "topAttributes": {
91
+ "Effector": [
92
+ {
93
+ "count": 4,
94
+ "value": "Two-finger"
95
+ },
96
+ {
97
+ "count": 1,
98
+ "value": "Suction"
99
+ },
100
+ {
101
+ "count": 1,
102
+ "value": "Multi-finger"
103
+ }
104
+ ],
105
+ "Input": [
106
+ {
107
+ "count": 2,
108
+ "value": "Point cloud"
109
+ },
110
+ {
111
+ "count": 1,
112
+ "value": "3D object model mesh"
113
+ },
114
+ {
115
+ "count": 1,
116
+ "value": "Depth image"
117
+ }
118
+ ],
119
+ "ObjConfig": [
120
+ {
121
+ "count": 6,
122
+ "value": "Singulated"
123
+ }
124
+ ],
125
+ "Plan": [
126
+ {
127
+ "count": 5,
128
+ "value": "Direct regression"
129
+ },
130
+ {
131
+ "count": 1,
132
+ "value": "Sampling"
133
+ },
134
+ {
135
+ "count": 1,
136
+ "value": "Exemplar methods"
137
+ }
138
+ ],
139
+ "Train": [
140
+ {
141
+ "count": 5,
142
+ "value": "Sim"
143
+ },
144
+ {
145
+ "count": 1,
146
+ "value": "Real"
147
+ }
148
+ ]
149
+ }
150
+ },
151
+ {
152
+ "id": 2,
153
+ "label": "Grasp evaluation + Sampler / Two-finger / Singulated",
154
+ "methods": [
155
+ "\ud83e\udd16 GraspMolmo",
156
+ "FoundationGrasp",
157
+ "GraspGen",
158
+ "GraspGPT",
159
+ "NeuGraspNet",
160
+ "ZeroGrasp"
161
+ ],
162
+ "size": 6,
163
+ "topAttributes": {
164
+ "Effector": [
165
+ {
166
+ "count": 6,
167
+ "value": "Two-finger"
168
+ },
169
+ {
170
+ "count": 1,
171
+ "value": "Suction"
172
+ }
173
+ ],
174
+ "Input": [
175
+ {
176
+ "count": 3,
177
+ "value": "RGBD image"
178
+ },
179
+ {
180
+ "count": 3,
181
+ "value": "Natural language"
182
+ },
183
+ {
184
+ "count": 3,
185
+ "value": "Point cloud"
186
+ }
187
+ ],
188
+ "ObjConfig": [
189
+ {
190
+ "count": 3,
191
+ "value": "Singulated"
192
+ },
193
+ {
194
+ "count": 1,
195
+ "value": "Packed"
196
+ },
197
+ {
198
+ "count": 1,
199
+ "value": "Piled"
200
+ }
201
+ ],
202
+ "Plan": [
203
+ {
204
+ "count": 5,
205
+ "value": "Grasp evaluation + Sampler"
206
+ },
207
+ {
208
+ "count": 1,
209
+ "value": "Generative"
210
+ }
211
+ ],
212
+ "Train": [
213
+ {
214
+ "count": 6,
215
+ "value": "Sim"
216
+ },
217
+ {
218
+ "count": 1,
219
+ "value": "Real"
220
+ }
221
+ ]
222
+ }
223
+ },
224
+ {
225
+ "id": 3,
226
+ "label": "Sampling / Two-finger / Piled",
227
+ "methods": [
228
+ "\ud83e\udd16 Equivariant Volumetric Grasping",
229
+ "CaTGrasp",
230
+ "Edge Grasp Network",
231
+ "GeomPickPlace",
232
+ "Grasp detection via Implicit Geometry and Affordance (GIGA)",
233
+ "Grasp Pose Detection (GPD)",
234
+ "ICG-Net",
235
+ "MultiModalGrasping",
236
+ "OrbitGrasp (EquiFormerV2)",
237
+ "PointNetGPD",
238
+ "Single-Shot SE(3) Grasp Detection (S4G)"
239
+ ],
240
+ "size": 11,
241
+ "topAttributes": {
242
+ "Effector": [
243
+ {
244
+ "count": 10,
245
+ "value": "Two-finger"
246
+ },
247
+ {
248
+ "count": 1,
249
+ "value": "Multi-finger"
250
+ }
251
+ ],
252
+ "Input": [
253
+ {
254
+ "count": 9,
255
+ "value": "Point cloud"
256
+ },
257
+ {
258
+ "count": 2,
259
+ "value": "Truncated Signed Distance Function (TSDF)"
260
+ }
261
+ ],
262
+ "ObjConfig": [
263
+ {
264
+ "count": 6,
265
+ "value": "Piled"
266
+ },
267
+ {
268
+ "count": 4,
269
+ "value": "Structured"
270
+ },
271
+ {
272
+ "count": 1,
273
+ "value": "Cluttered"
274
+ }
275
+ ],
276
+ "Plan": [
277
+ {
278
+ "count": 8,
279
+ "value": "Sampling"
280
+ },
281
+ {
282
+ "count": 2,
283
+ "value": "Direct regression"
284
+ },
285
+ {
286
+ "count": 1,
287
+ "value": "Generative"
288
+ }
289
+ ],
290
+ "Train": [
291
+ {
292
+ "count": 11,
293
+ "value": "Sim"
294
+ },
295
+ {
296
+ "count": 1,
297
+ "value": "Real"
298
+ }
299
+ ]
300
+ }
301
+ },
302
+ {
303
+ "id": 4,
304
+ "label": "Reinforcement learning / Two-finger / Piled",
305
+ "methods": [
306
+ "DeepRLManip",
307
+ "Dex-Net 2.1",
308
+ "Dex-Net 4.0"
309
+ ],
310
+ "size": 3,
311
+ "topAttributes": {
312
+ "Effector": [
313
+ {
314
+ "count": 3,
315
+ "value": "Two-finger"
316
+ },
317
+ {
318
+ "count": 1,
319
+ "value": "Suction"
320
+ }
321
+ ],
322
+ "Input": [
323
+ {
324
+ "count": 2,
325
+ "value": "Depth image"
326
+ },
327
+ {
328
+ "count": 1,
329
+ "value": "Rollouts"
330
+ }
331
+ ],
332
+ "ObjConfig": [
333
+ {
334
+ "count": 2,
335
+ "value": "Piled"
336
+ },
337
+ {
338
+ "count": 1,
339
+ "value": "Structured"
340
+ }
341
+ ],
342
+ "Plan": [
343
+ {
344
+ "count": 3,
345
+ "value": "Reinforcement learning"
346
+ }
347
+ ],
348
+ "Train": [
349
+ {
350
+ "count": 3,
351
+ "value": "Sim"
352
+ }
353
+ ]
354
+ }
355
+ },
356
+ {
357
+ "id": 5,
358
+ "label": "Direct regression / Two-finger / Structured",
359
+ "methods": [
360
+ "RGBD-Grasp",
361
+ "ROI-GD"
362
+ ],
363
+ "size": 2,
364
+ "topAttributes": {
365
+ "Effector": [
366
+ {
367
+ "count": 2,
368
+ "value": "Two-finger"
369
+ }
370
+ ],
371
+ "Input": [
372
+ {
373
+ "count": 2,
374
+ "value": "RGBD image"
375
+ }
376
+ ],
377
+ "ObjConfig": [
378
+ {
379
+ "count": 1,
380
+ "value": "Structured"
381
+ },
382
+ {
383
+ "count": 1,
384
+ "value": "Piled"
385
+ }
386
+ ],
387
+ "Plan": [
388
+ {
389
+ "count": 1,
390
+ "value": "Direct regression"
391
+ },
392
+ {
393
+ "count": 1,
394
+ "value": "ROI-based"
395
+ }
396
+ ],
397
+ "Train": [
398
+ {
399
+ "count": 2,
400
+ "value": "Real"
401
+ },
402
+ {
403
+ "count": 1,
404
+ "value": "Sim"
405
+ }
406
+ ]
407
+ }
408
+ },
409
+ {
410
+ "id": 6,
411
+ "label": "Direct regression / Two-finger / Piled",
412
+ "methods": [
413
+ "AnyGrasp",
414
+ "Grasp Proposal Network (GPNet)",
415
+ "PointNet++ Grasping",
416
+ "REgion-based Grasp Network (REGNet)",
417
+ "Volumetric Grasping Network (VGN)"
418
+ ],
419
+ "size": 5,
420
+ "topAttributes": {
421
+ "Effector": [
422
+ {
423
+ "count": 5,
424
+ "value": "Two-finger"
425
+ }
426
+ ],
427
+ "Input": [
428
+ {
429
+ "count": 4,
430
+ "value": "Point cloud"
431
+ },
432
+ {
433
+ "count": 1,
434
+ "value": "Truncated Signed Distance Function (TSDF)"
435
+ }
436
+ ],
437
+ "ObjConfig": [
438
+ {
439
+ "count": 4,
440
+ "value": "Piled"
441
+ },
442
+ {
443
+ "count": 1,
444
+ "value": "Singulated"
445
+ }
446
+ ],
447
+ "Plan": [
448
+ {
449
+ "count": 5,
450
+ "value": "Direct regression"
451
+ }
452
+ ],
453
+ "Train": [
454
+ {
455
+ "count": 5,
456
+ "value": "Sim"
457
+ },
458
+ {
459
+ "count": 1,
460
+ "value": "Real"
461
+ }
462
+ ]
463
+ }
464
+ },
465
+ {
466
+ "id": 7,
467
+ "label": "Grasp evaluation + Sampler / Two-finger / Cluttered",
468
+ "methods": [
469
+ "7DGCG",
470
+ "ShapeGrasp"
471
+ ],
472
+ "size": 2,
473
+ "topAttributes": {
474
+ "Effector": [
475
+ {
476
+ "count": 1,
477
+ "value": "Two-finger"
478
+ },
479
+ {
480
+ "count": 1,
481
+ "value": "Three-finger"
482
+ }
483
+ ],
484
+ "Input": [
485
+ {
486
+ "count": 2,
487
+ "value": "RGBD image"
488
+ },
489
+ {
490
+ "count": 1,
491
+ "value": "Point cloud"
492
+ }
493
+ ],
494
+ "ObjConfig": [
495
+ {
496
+ "count": 1,
497
+ "value": "Cluttered"
498
+ },
499
+ {
500
+ "count": 1,
501
+ "value": "Singulated"
502
+ }
503
+ ],
504
+ "Plan": [
505
+ {
506
+ "count": 2,
507
+ "value": "Grasp evaluation + Sampler"
508
+ }
509
+ ],
510
+ "Train": [
511
+ {
512
+ "count": 2,
513
+ "value": "Training-less"
514
+ }
515
+ ]
516
+ }
517
+ },
518
+ {
519
+ "id": 8,
520
+ "label": "Generative / Multi-finger / Singulated",
521
+ "methods": [
522
+ "\ud83e\udd16 GraspQP",
523
+ "DexDiffuser",
524
+ "DexGrasp Anything"
525
+ ],
526
+ "size": 3,
527
+ "topAttributes": {
528
+ "Effector": [
529
+ {
530
+ "count": 3,
531
+ "value": "Multi-finger"
532
+ },
533
+ {
534
+ "count": 1,
535
+ "value": "Two-finger"
536
+ },
537
+ {
538
+ "count": 1,
539
+ "value": "Three-finger"
540
+ }
541
+ ],
542
+ "Input": [
543
+ {
544
+ "count": 2,
545
+ "value": "Point cloud"
546
+ },
547
+ {
548
+ "count": 1,
549
+ "value": "3D object model mesh"
550
+ }
551
+ ],
552
+ "ObjConfig": [
553
+ {
554
+ "count": 3,
555
+ "value": "Singulated"
556
+ }
557
+ ],
558
+ "Plan": [
559
+ {
560
+ "count": 2,
561
+ "value": "Generative"
562
+ },
563
+ {
564
+ "count": 1,
565
+ "value": "Analytical"
566
+ },
567
+ {
568
+ "count": 1,
569
+ "value": "Sampling"
570
+ }
571
+ ],
572
+ "Train": [
573
+ {
574
+ "count": 2,
575
+ "value": "Sim"
576
+ },
577
+ {
578
+ "count": 1,
579
+ "value": "Training-less"
580
+ },
581
+ {
582
+ "count": 1,
583
+ "value": "Real"
584
+ }
585
+ ]
586
+ }
587
+ },
588
+ {
589
+ "id": 9,
590
+ "label": "Reinforcement learning / Multi-finger / Singulated",
591
+ "methods": [
592
+ "\ud83e\udd16 GraspVLA",
593
+ "Goal-Auxiliary Deep Deterministic Policy Gradient (GA-DDPG)",
594
+ "GRAFF (Grasp-Affordances)",
595
+ "GraspXL",
596
+ "Probablistic Multi-fingered Grasp Planner",
597
+ "RobustDexGrasp",
598
+ "UniGraspTransformer"
599
+ ],
600
+ "size": 7,
601
+ "topAttributes": {
602
+ "Effector": [
603
+ {
604
+ "count": 5,
605
+ "value": "Multi-finger"
606
+ },
607
+ {
608
+ "count": 2,
609
+ "value": "Two-finger"
610
+ }
611
+ ],
612
+ "Input": [
613
+ {
614
+ "count": 5,
615
+ "value": "Point cloud"
616
+ },
617
+ {
618
+ "count": 1,
619
+ "value": "RGB image"
620
+ },
621
+ {
622
+ "count": 1,
623
+ "value": "RGBD image"
624
+ }
625
+ ],
626
+ "ObjConfig": [
627
+ {
628
+ "count": 7,
629
+ "value": "Singulated"
630
+ },
631
+ {
632
+ "count": 1,
633
+ "value": "Packed"
634
+ }
635
+ ],
636
+ "Plan": [
637
+ {
638
+ "count": 5,
639
+ "value": "Reinforcement learning"
640
+ },
641
+ {
642
+ "count": 1,
643
+ "value": "Generative"
644
+ },
645
+ {
646
+ "count": 1,
647
+ "value": "Active learning"
648
+ }
649
+ ],
650
+ "Train": [
651
+ {
652
+ "count": 6,
653
+ "value": "Sim"
654
+ },
655
+ {
656
+ "count": 1,
657
+ "value": "Real"
658
+ }
659
+ ]
660
+ }
661
+ },
662
+ {
663
+ "id": 10,
664
+ "label": "Sampling / Three-finger / Singulated",
665
+ "methods": [
666
+ "Deep Dexterous Grasping in Clutter (DDGC)",
667
+ "geometric-object-grasper",
668
+ "Multi-FinGAN",
669
+ "Robust Grasp Planning Over Uncertain Shape Completions"
670
+ ],
671
+ "size": 4,
672
+ "topAttributes": {
673
+ "Effector": [
674
+ {
675
+ "count": 4,
676
+ "value": "Three-finger"
677
+ }
678
+ ],
679
+ "Input": [
680
+ {
681
+ "count": 2,
682
+ "value": "RGBD image"
683
+ },
684
+ {
685
+ "count": 1,
686
+ "value": "Point cloud"
687
+ },
688
+ {
689
+ "count": 1,
690
+ "value": "voxelized occupancy grid"
691
+ }
692
+ ],
693
+ "ObjConfig": [
694
+ {
695
+ "count": 2,
696
+ "value": "Singulated"
697
+ },
698
+ {
699
+ "count": 1,
700
+ "value": "Structured"
701
+ },
702
+ {
703
+ "count": 1,
704
+ "value": "Piled"
705
+ }
706
+ ],
707
+ "Plan": [
708
+ {
709
+ "count": 2,
710
+ "value": "Sampling"
711
+ },
712
+ {
713
+ "count": 2,
714
+ "value": "Analytical"
715
+ }
716
+ ],
717
+ "Train": [
718
+ {
719
+ "count": 4,
720
+ "value": "Sim"
721
+ }
722
+ ]
723
+ }
724
+ }
725
+ ]
726
+ }
frontend/src/utils.js ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Split a comma-separated string, respecting double-quoted fields.
3
+ * "A, B" -> ['A', 'B']
4
+ * 'Dexterous grasp, "6-DoF grasp pose (x, y, z, r, p, y)"'
5
+ * -> ['Dexterous grasp', '6-DoF grasp pose (x, y, z, r, p, y)']
6
+ */
7
+ export function smartSplit(value) {
8
+ if (!value) return [];
9
+ const result = [];
10
+ let current = '';
11
+ let inQuotes = false;
12
+ for (let i = 0; i < value.length; i++) {
13
+ const ch = value[i];
14
+ if (ch === '"') {
15
+ inQuotes = !inQuotes;
16
+ } else if (ch === ',' && !inQuotes) {
17
+ const trimmed = current.trim();
18
+ if (trimmed) result.push(trimmed);
19
+ current = '';
20
+ } else {
21
+ current += ch;
22
+ }
23
+ }
24
+ const trimmed = current.trim();
25
+ if (trimmed) result.push(trimmed);
26
+ return result;
27
+ }