Spaces:
Running
Running
Commit ·
3c0f98c
1
Parent(s): 2ede05f
AI-in-the-loop
Browse files- .env.example +3 -0
- .gitignore +4 -0
- Dockerfile +2 -1
- backend/app.py +808 -69
- backend/requirements.txt +1 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +25 -10
- frontend/public/favicon.ico +0 -0
- frontend/public/index.html +11 -8
- frontend/src/App.css +400 -61
- frontend/src/App.js +167 -339
- frontend/src/ClusterFeatureGrid.css +0 -230
- frontend/src/ClusterFeatureGrid.js +0 -217
- frontend/src/components/ClusterOverview.js +44 -0
- frontend/src/components/DetailPanel.js +31 -0
- frontend/src/components/InsightBullets.js +16 -0
- frontend/src/components/InsightCard.js +59 -0
- frontend/src/components/MethodTable.js +71 -0
- frontend/src/components/ScatterPlot.js +135 -0
- frontend/src/constants.js +31 -0
- frontend/src/index.js +5 -1
- frontend/src/staticClusterInsight.json +726 -0
- frontend/src/utils.js +27 -0
.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 -
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 73 |
random_state=UMAP_RANDOM_STATE,
|
| 74 |
-
n_components=2
|
|
|
|
| 75 |
)
|
| 76 |
-
return reducer.fit_transform(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
@app.route('/api/umap', methods=['GET', 'POST'])
|
| 79 |
def get_umap():
|
| 80 |
"""Compute and return UMAP projection with metadata."""
|
| 81 |
try:
|
| 82 |
-
|
| 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 |
-
|
| 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':
|
| 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 =
|
| 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-
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
"dependencies": {
|
| 6 |
-
"
|
| 7 |
-
"
|
| 8 |
-
"
|
| 9 |
-
"
|
|
|
|
|
|
|
|
|
|
| 10 |
"react-plotly.js": "^2.6.0",
|
| 11 |
-
"
|
|
|
|
| 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": [
|
|
|
|
|
|
|
|
|
|
| 22 |
},
|
|
|
|
| 23 |
"browserslist": {
|
| 24 |
-
"production": [
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 6 |
-
<
|
| 7 |
-
</
|
| 8 |
-
<
|
| 9 |
-
<
|
| 10 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
.
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
/*
|
| 16 |
-
.
|
| 17 |
-
.
|
| 18 |
-
.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
.
|
| 27 |
-
.
|
| 28 |
-
.
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
.
|
| 34 |
-
.
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
.
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
.
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 2 |
-
import
|
| 3 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
const [hoveredIndex, setHoveredIndex] = useState(null);
|
| 51 |
-
const [
|
| 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
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
| 68 |
method: 'POST',
|
| 69 |
headers: { 'Content-Type': 'application/json' },
|
| 70 |
-
body: JSON.stringify(
|
| 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.
|
| 81 |
-
|
| 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
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 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 |
-
|
| 148 |
-
bVal = b.metadata[sortConfig.key] || '';
|
| 149 |
}
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 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
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
};
|
| 191 |
|
| 192 |
if (loading) {
|
| 193 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
if (error) {
|
| 197 |
-
return
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 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 |
-
|
| 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="
|
| 262 |
-
<header className="
|
| 263 |
-
<
|
| 264 |
-
|
| 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 |
-
<
|
| 287 |
-
<
|
| 288 |
-
<
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
/>
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
</div>
|
|
|
|
| 300 |
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
<
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 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 |
-
|
| 377 |
-
<ClusterFeatureGrid
|
| 378 |
data={data}
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
| 383 |
/>
|
| 384 |
-
|
| 385 |
|
| 386 |
-
{selectedPoint
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 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 · COMPARE Project · 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}>×</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}>×</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} → <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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|