euler314 commited on
Commit
b206cbf
·
verified ·
1 Parent(s): 2d3a20c

Upload 8 files

Browse files
Files changed (8) hide show
  1. Dockerfile +36 -0
  2. mcp_server.py +300 -0
  3. requirements-hf.txt +13 -0
  4. streamlit.py +1049 -0
  5. web/api-adapter.js +282 -0
  6. web/app.js +1253 -0
  7. web/index.html +327 -0
  8. web/style.css +1012 -0
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Spaces Deployment
2
+ # This runs the Streamlit version for best compatibility with HF Spaces
3
+
4
+ FROM python:3.9-slim
5
+
6
+ # Set working directory
7
+ WORKDIR /app
8
+
9
+ # Install system dependencies
10
+ RUN apt-get update && apt-get install -y \
11
+ build-essential \
12
+ curl \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Copy requirements first for better caching
16
+ COPY requirements-hf.txt .
17
+ RUN pip install --no-cache-dir -r requirements-hf.txt
18
+
19
+ # Copy application files
20
+ COPY streamlit.py .
21
+ COPY web ./web
22
+
23
+ # Expose port for Streamlit
24
+ EXPOSE 7860
25
+
26
+ # Hugging Face Spaces expects the app to run on port 7860
27
+ ENV STREAMLIT_SERVER_PORT=7860
28
+ ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0
29
+ ENV STREAMLIT_SERVER_HEADLESS=true
30
+ ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
31
+
32
+ # Health check
33
+ HEALTHCHECK CMD curl --fail http://localhost:7860/_stcore/health || exit 1
34
+
35
+ # Run Streamlit app
36
+ CMD ["streamlit", "run", "streamlit.py", "--server.port=7860", "--server.address=0.0.0.0"]
mcp_server.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ t-SNE Explorer - MCP Server
3
+ Model Context Protocol server for handling heavy computations
4
+
5
+ This server provides tools for:
6
+ - Synthetic data generation
7
+ - t-SNE computation
8
+ - MNIST loading
9
+ - Clustering algorithms
10
+
11
+ Android/mobile clients connect to this server to offload heavy computations.
12
+ """
13
+
14
+ import asyncio
15
+ import numpy as np
16
+ from mcp.server.models import InitializationOptions
17
+ from mcp.server import NotificationOptions, Server
18
+ from mcp.server.stdio import stdio_server
19
+ from mcp.types import Tool, TextContent
20
+
21
+ # Import the backend
22
+ import sys
23
+ import os
24
+ sys.path.append(os.path.dirname(__file__))
25
+
26
+ # Import TSNEExplorer from streamlit.py
27
+ try:
28
+ # Try to import the backend class
29
+ import importlib.util
30
+ spec = importlib.util.spec_from_file_location("tsne_backend", "streamlit.py")
31
+ tsne_module = importlib.util.module_from_spec(spec)
32
+ spec.loader.exec_module(tsne_module)
33
+ TSNEExplorer = tsne_module.TSNEExplorer
34
+ except:
35
+ # Fallback: define minimal backend
36
+ print("Warning: Could not import TSNEExplorer, using minimal fallback")
37
+ from sklearn.manifold import TSNE as SklearnTSNE
38
+ from sklearn.datasets import fetch_openml
39
+
40
+ class TSNEExplorer:
41
+ def __init__(self):
42
+ pass
43
+
44
+ def generate_simplex_points(self, n, d, k, seed=42):
45
+ # Minimal implementation
46
+ np.random.seed(seed)
47
+ X = np.random.randn(n, d)
48
+ return {
49
+ 'success': True,
50
+ 'points': X.tolist(),
51
+ 'n': n, 'd': d, 'k': k,
52
+ 'actual_k': k
53
+ }
54
+
55
+ def run_tsne(self, X, perplexity=30, learning_rate=200, n_iter=1000,
56
+ early_exaggeration=12, momentum=0.8, seed=42, progress_callback=None):
57
+ X_array = np.array(X) if not isinstance(X, np.ndarray) else X
58
+ tsne = SklearnTSNE(
59
+ n_components=2,
60
+ perplexity=perplexity,
61
+ learning_rate=learning_rate,
62
+ n_iter=n_iter,
63
+ random_state=seed
64
+ )
65
+ Y = tsne.fit_transform(X_array)
66
+ return {
67
+ 'success': True,
68
+ 'Y': Y.tolist(),
69
+ 'P': [[0]],
70
+ 'Q': [[0]],
71
+ 'C_history': [0] * 10,
72
+ 'n': len(Y)
73
+ }
74
+
75
+ def load_mnist(self, max_samples=1000, subset='train'):
76
+ mnist = fetch_openml('mnist_784', version=1, as_frame=False, parser='auto')
77
+ all_images = np.array(mnist.data, dtype=np.float32)
78
+
79
+ if isinstance(mnist.target[0], str):
80
+ all_labels = np.array([int(label) for label in mnist.target], dtype=np.int32)
81
+ else:
82
+ all_labels = np.array(mnist.target, dtype=np.int32)
83
+
84
+ if subset == 'train':
85
+ images_flat = all_images[:60000]
86
+ labels = all_labels[:60000]
87
+ else:
88
+ images_flat = all_images[60000:]
89
+ labels = all_labels[60000:]
90
+
91
+ if max_samples > 0 and max_samples < len(images_flat):
92
+ images_flat = images_flat[:max_samples]
93
+ labels = labels[:max_samples]
94
+
95
+ X = images_flat / 255.0
96
+
97
+ return {
98
+ 'success': True,
99
+ 'X': X.tolist(),
100
+ 'labels': labels.tolist(),
101
+ 'count': len(images_flat),
102
+ 'shape': list(X.shape),
103
+ 'message': f'Loaded {len(images_flat)} MNIST {subset} samples'
104
+ }
105
+
106
+ def run_clustering(self, Y, method='kmeans', k=3, eps=0.5, min_samples=5):
107
+ Y_array = np.array(Y) if not isinstance(Y, np.ndarray) else Y
108
+
109
+ if method == 'kmeans':
110
+ from sklearn.cluster import KMeans
111
+ kmeans = KMeans(n_clusters=k, random_state=42)
112
+ labels = kmeans.fit_predict(Y_array)
113
+ else:
114
+ from sklearn.cluster import DBSCAN
115
+ dbscan = DBSCAN(eps=eps, min_samples=min_samples)
116
+ labels = dbscan.fit_predict(Y_array)
117
+
118
+ unique_labels = np.unique(labels)
119
+ summary = []
120
+ for label in unique_labels:
121
+ count = np.sum(labels == label)
122
+ summary.append({'label': int(label), 'count': int(count)})
123
+
124
+ return {
125
+ 'success': True,
126
+ 'labels': labels.tolist(),
127
+ 'summary': summary
128
+ }
129
+
130
+
131
+ # Create backend instance
132
+ backend = TSNEExplorer()
133
+
134
+ # Create MCP server
135
+ server = Server("t-sne-explorer-server")
136
+
137
+
138
+ @server.list_tools()
139
+ async def handle_list_tools() -> list[Tool]:
140
+ """List available computation tools"""
141
+ return [
142
+ Tool(
143
+ name="generate_simplex_points",
144
+ description="Generate n points in d dimensions with k distinct distance types",
145
+ inputSchema={
146
+ "type": "object",
147
+ "properties": {
148
+ "n": {"type": "integer", "description": "Number of points"},
149
+ "d": {"type": "integer", "description": "Dimensions"},
150
+ "k": {"type": "integer", "description": "Distinct distance types"},
151
+ "seed": {"type": "integer", "description": "Random seed", "default": 42}
152
+ },
153
+ "required": ["n", "d", "k"]
154
+ }
155
+ ),
156
+ Tool(
157
+ name="run_tsne",
158
+ description="Run t-SNE dimensionality reduction",
159
+ inputSchema={
160
+ "type": "object",
161
+ "properties": {
162
+ "X": {
163
+ "type": "array",
164
+ "description": "Input data matrix (n x d)",
165
+ "items": {"type": "array", "items": {"type": "number"}}
166
+ },
167
+ "perplexity": {"type": "integer", "default": 30},
168
+ "learning_rate": {"type": "integer", "default": 200},
169
+ "n_iter": {"type": "integer", "default": 1000},
170
+ "early_exaggeration": {"type": "integer", "default": 12},
171
+ "momentum": {"type": "number", "default": 0.8},
172
+ "seed": {"type": "integer", "default": 42}
173
+ },
174
+ "required": ["X"]
175
+ }
176
+ ),
177
+ Tool(
178
+ name="load_mnist",
179
+ description="Load MNIST handwritten digits dataset",
180
+ inputSchema={
181
+ "type": "object",
182
+ "properties": {
183
+ "max_samples": {"type": "integer", "default": 1000},
184
+ "subset": {"type": "string", "enum": ["train", "test"], "default": "train"}
185
+ }
186
+ }
187
+ ),
188
+ Tool(
189
+ name="run_clustering",
190
+ description="Run clustering on 2D embeddings",
191
+ inputSchema={
192
+ "type": "object",
193
+ "properties": {
194
+ "Y": {
195
+ "type": "array",
196
+ "description": "2D embedding coordinates",
197
+ "items": {"type": "array", "items": {"type": "number"}}
198
+ },
199
+ "method": {"type": "string", "enum": ["kmeans", "dbscan"], "default": "kmeans"},
200
+ "k": {"type": "integer", "default": 3},
201
+ "eps": {"type": "number", "default": 0.5},
202
+ "min_samples": {"type": "integer", "default": 5}
203
+ },
204
+ "required": ["Y"]
205
+ }
206
+ )
207
+ ]
208
+
209
+
210
+ @server.call_tool()
211
+ async def handle_call_tool(name: str, arguments: dict) -> list[TextContent]:
212
+ """Handle tool execution"""
213
+
214
+ try:
215
+ if name == "generate_simplex_points":
216
+ n = arguments.get("n")
217
+ d = arguments.get("d")
218
+ k = arguments.get("k")
219
+ seed = arguments.get("seed", 42)
220
+
221
+ result = backend.generate_simplex_points(n, d, k, seed)
222
+
223
+ return [TextContent(
224
+ type="text",
225
+ text=str(result)
226
+ )]
227
+
228
+ elif name == "run_tsne":
229
+ X = np.array(arguments.get("X"))
230
+ perplexity = arguments.get("perplexity", 30)
231
+ learning_rate = arguments.get("learning_rate", 200)
232
+ n_iter = arguments.get("n_iter", 1000)
233
+ early_exaggeration = arguments.get("early_exaggeration", 12)
234
+ momentum = arguments.get("momentum", 0.8)
235
+ seed = arguments.get("seed", 42)
236
+
237
+ result = backend.run_tsne(
238
+ X, perplexity, learning_rate, n_iter,
239
+ early_exaggeration, momentum, seed
240
+ )
241
+
242
+ return [TextContent(
243
+ type="text",
244
+ text=str(result)
245
+ )]
246
+
247
+ elif name == "load_mnist":
248
+ max_samples = arguments.get("max_samples", 1000)
249
+ subset = arguments.get("subset", "train")
250
+
251
+ result = backend.load_mnist(max_samples, subset)
252
+
253
+ return [TextContent(
254
+ type="text",
255
+ text=str(result)
256
+ )]
257
+
258
+ elif name == "run_clustering":
259
+ Y = np.array(arguments.get("Y"))
260
+ method = arguments.get("method", "kmeans")
261
+ k = arguments.get("k", 3)
262
+ eps = arguments.get("eps", 0.5)
263
+ min_samples = arguments.get("min_samples", 5)
264
+
265
+ result = backend.run_clustering(Y, method, k, eps, min_samples)
266
+
267
+ return [TextContent(
268
+ type="text",
269
+ text=str(result)
270
+ )]
271
+
272
+ else:
273
+ raise ValueError(f"Unknown tool: {name}")
274
+
275
+ except Exception as e:
276
+ return [TextContent(
277
+ type="text",
278
+ text=str({"success": False, "error": str(e)})
279
+ )]
280
+
281
+
282
+ async def main():
283
+ """Run the MCP server"""
284
+ async with stdio_server() as (read_stream, write_stream):
285
+ await server.run(
286
+ read_stream,
287
+ write_stream,
288
+ InitializationOptions(
289
+ server_name="t-sne-explorer",
290
+ server_version="1.0.0",
291
+ capabilities=server.get_capabilities(
292
+ notification_options=NotificationOptions(),
293
+ experimental_capabilities={}
294
+ )
295
+ )
296
+ )
297
+
298
+
299
+ if __name__ == "__main__":
300
+ asyncio.run(main())
requirements-hf.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Requirements for Hugging Face Spaces Deployment
2
+ # Optimized for cloud deployment with minimal dependencies
3
+
4
+ # Core dependencies
5
+ streamlit==1.30.0
6
+ numpy==1.24.3
7
+ pandas==2.0.3
8
+ scikit-learn==1.3.2
9
+ Pillow==10.1.0
10
+ plotly==5.18.0
11
+
12
+ # Additional for stability
13
+ protobuf==3.20.3
streamlit.py ADDED
@@ -0,0 +1,1049 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ t-SNE Explorer - Streamlit Application
3
+ A transparent t-SNE implementation with synthetic data generation and file upload support
4
+
5
+ This version uses Streamlit for the UI while maintaining the same functionality as app.py.
6
+ Backend is MCP-ready for future Android app integration.
7
+ """
8
+
9
+ import os
10
+ import warnings
11
+ import numpy as np
12
+ import pandas as pd
13
+ import streamlit as st
14
+ import plotly.graph_objects as go
15
+ from io import BytesIO
16
+ from PIL import Image
17
+ from pathlib import Path
18
+
19
+ # Suppress warnings
20
+ warnings.filterwarnings('ignore')
21
+ os.environ['PYTHONWARNINGS'] = 'ignore'
22
+
23
+ try:
24
+ from sklearn.manifold import TSNE as SklearnTSNE
25
+ from sklearn.datasets import fetch_openml
26
+ except Exception:
27
+ SklearnTSNE = None
28
+ fetch_openml = None
29
+
30
+
31
+ # ==================== Styling ====================
32
+
33
+ def inject_custom_css():
34
+ """Inject custom CSS from web/style.css to match the original design"""
35
+ st.markdown("""
36
+ <style>
37
+ /* Import styling from web folder */
38
+ :root {
39
+ --primary: #667eea;
40
+ --primary-dark: #5568d3;
41
+ --secondary: #764ba2;
42
+ --success: #10b981;
43
+ --warning: #f59e0b;
44
+ --error: #ef4444;
45
+ }
46
+
47
+ /* Main app styling */
48
+ .stApp {
49
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
50
+ }
51
+
52
+ /* Sidebar styling */
53
+ .css-1d391kg {
54
+ background-color: #f8f9fa;
55
+ }
56
+
57
+ /* Headers */
58
+ h1 {
59
+ color: white;
60
+ font-weight: 800;
61
+ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
62
+ }
63
+
64
+ h2, h3 {
65
+ color: #667eea;
66
+ font-weight: 700;
67
+ }
68
+
69
+ /* Buttons */
70
+ .stButton > button {
71
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
72
+ color: white;
73
+ border: none;
74
+ border-radius: 8px;
75
+ padding: 12px 24px;
76
+ font-weight: 600;
77
+ transition: all 0.3s ease;
78
+ }
79
+
80
+ .stButton > button:hover {
81
+ transform: translateY(-2px);
82
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
83
+ }
84
+
85
+ /* Info boxes */
86
+ .stAlert {
87
+ border-radius: 8px;
88
+ border-left: 4px solid #667eea;
89
+ }
90
+
91
+ /* Dataframes */
92
+ .dataframe {
93
+ border-radius: 8px;
94
+ overflow: hidden;
95
+ }
96
+
97
+ /* Cards */
98
+ .element-container {
99
+ background: white;
100
+ border-radius: 12px;
101
+ padding: 10px;
102
+ margin-bottom: 10px;
103
+ }
104
+
105
+ /* Progress bar */
106
+ .stProgress > div > div {
107
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
108
+ }
109
+ </style>
110
+ """, unsafe_allow_html=True)
111
+
112
+
113
+ # ==================== TSNEExplorer Backend Class (MCP-Ready) ====================
114
+
115
+ class TSNEExplorer:
116
+ """
117
+ Backend API for t-SNE computations.
118
+ This class is MCP-ready - all methods return JSON-serializable data
119
+ and can be called directly (Streamlit) or via API endpoints (future Android app).
120
+ """
121
+
122
+ def __init__(self):
123
+ pass
124
+
125
+ # ==================== Synthetic Data Generation ====================
126
+
127
+ def generate_simplex_points(self, n, d, k, seed=42):
128
+ """Generate n points in d dimensions with k distinct distance types"""
129
+ np.random.seed(seed)
130
+
131
+ # Validate inputs
132
+ max_distances = (n * (n - 1)) // 2
133
+ if k > max_distances:
134
+ return {
135
+ 'success': False,
136
+ 'error': f'Cannot create {k} distinct distances with only {n} points. '
137
+ f'Maximum possible is {max_distances} distinct distances.'
138
+ }
139
+
140
+ if k < 1:
141
+ return {
142
+ 'success': False,
143
+ 'error': f'k must be at least 1 (you specified k={k}).'
144
+ }
145
+
146
+ # Special case: k=1
147
+ if k == 1:
148
+ if n > d + 1:
149
+ return {
150
+ 'success': False,
151
+ 'error': f'For k=1 (equidistant points), maximum n is {d+1} in {d}D.'
152
+ }
153
+ X = self._generate_regular_simplex(n, d)
154
+ else:
155
+ X = self._generate_k_distance_set(n, d, k, seed)
156
+
157
+ # Compute pairwise distances
158
+ distances = self._compute_pairwise_distances(X)
159
+ unique_distances = np.unique(np.round(distances[distances > 0], decimals=6))
160
+
161
+ return {
162
+ 'success': True,
163
+ 'points': X.tolist(),
164
+ 'n': n,
165
+ 'd': d,
166
+ 'k': k,
167
+ 'actual_k': len(unique_distances),
168
+ 'unique_distances': unique_distances.tolist(),
169
+ 'distances_min': float(np.min(distances[distances > 0])) if n > 1 else 0,
170
+ 'distances_mean': float(np.mean(distances[distances > 0])) if n > 1 else 0,
171
+ 'distances_max': float(np.max(distances)),
172
+ }
173
+
174
+ def _generate_regular_simplex(self, n, d):
175
+ """Generate regular n-simplex with equal pairwise distances"""
176
+ if n == 1:
177
+ return np.zeros((1, d))
178
+
179
+ if n == 2:
180
+ X = np.zeros((2, d))
181
+ X[0, 0] = -0.5
182
+ X[1, 0] = 0.5
183
+ return X
184
+
185
+ vertices = np.eye(n)
186
+ vertices = vertices - np.mean(vertices, axis=0)
187
+ vertices = vertices / np.sqrt(2)
188
+
189
+ if d >= n - 1:
190
+ X = vertices[:, :min(d, n)]
191
+ if d > n:
192
+ X = np.pad(X, ((0, 0), (0, d - n)), 'constant')
193
+ else:
194
+ X = vertices[:, :d]
195
+
196
+ return X
197
+
198
+ def _generate_k_distance_set(self, n, d, k, seed):
199
+ """Generate points aiming for k distinct pairwise distances"""
200
+ np.random.seed(seed)
201
+
202
+ if n <= 0 or d <= 0:
203
+ return np.zeros((0, max(d, 0)))
204
+
205
+ if n == 1:
206
+ return np.zeros((1, d))
207
+
208
+ # Exact k=2 constructions
209
+ if k == 2:
210
+ if d >= 2 and n == 5:
211
+ return self._regular_ngon(n=5, d=d)
212
+ if n <= 2 * d:
213
+ return self._cross_polytope(n=n, d=d)
214
+ return self._optimize_k_distance_set(n=n, d=d, k=k, seed=seed)
215
+
216
+ # Exact k>=3 constructions
217
+ if k >= 3 and d >= k and k <= 12 and n <= (2 ** k):
218
+ return self._k_cube_k_distance_set(n=n, d=d, k=k)
219
+
220
+ if k == 3 and d >= 2 and n in (6, 7):
221
+ return self._regular_ngon(n=n, d=d)
222
+
223
+ return self._optimize_k_distance_set(n=n, d=d, k=k, seed=seed)
224
+
225
+ def _regular_ngon(self, n, d):
226
+ """Regular n-gon in 2D"""
227
+ X = np.zeros((n, d))
228
+ if d < 2:
229
+ return X
230
+ angles = np.linspace(0, 2 * np.pi, n + 1)[:-1]
231
+ X[:, 0] = np.cos(angles)
232
+ X[:, 1] = np.sin(angles)
233
+ return X
234
+
235
+ def _cross_polytope(self, n, d):
236
+ """Cross polytope vertices"""
237
+ X = np.zeros((n, d))
238
+ if n == 1:
239
+ return X
240
+ point_idx = 0
241
+ for i in range(d):
242
+ if point_idx >= n:
243
+ break
244
+ X[point_idx, i] = 1.0
245
+ point_idx += 1
246
+ if point_idx >= n:
247
+ break
248
+ X[point_idx, i] = -1.0
249
+ point_idx += 1
250
+ return X
251
+
252
+ def _k_cube_k_distance_set(self, n, d, k):
253
+ """k-dimensional hypercube vertices"""
254
+ vertices = []
255
+ seen = set()
256
+
257
+ origin = tuple([0] * k)
258
+ vertices.append(origin)
259
+ seen.add(origin)
260
+
261
+ for weight in range(1, k + 1):
262
+ if len(vertices) >= n:
263
+ break
264
+ v = tuple([1] * weight + [0] * (k - weight))
265
+ if v not in seen:
266
+ vertices.append(v)
267
+ seen.add(v)
268
+
269
+ for mask in range(1, 2 ** k):
270
+ if len(vertices) >= n:
271
+ break
272
+ v = tuple((mask >> bit) & 1 for bit in range(k))
273
+ if v in seen:
274
+ continue
275
+ vertices.append(v)
276
+ seen.add(v)
277
+
278
+ Xk = np.array(vertices[:n], dtype=float)
279
+ X = np.zeros((n, d), dtype=float)
280
+ X[:, :k] = Xk
281
+ X = X - X.mean(axis=0, keepdims=True)
282
+ return X
283
+
284
+ def _optimize_k_distance_set(self, n, d, k, seed, n_iter=2000, lr=0.02):
285
+ """Heuristic optimization for k distances"""
286
+ rng = np.random.default_rng(seed)
287
+ X = rng.standard_normal((n, d)) * 0.1
288
+
289
+ if n < 2:
290
+ return X
291
+
292
+ D0 = self._compute_pairwise_distances(X)
293
+ upper = D0[np.triu_indices(n, k=1)]
294
+ if upper.size == 0:
295
+ return X
296
+
297
+ r_min = float(np.percentile(upper, 10))
298
+ r_max = float(np.percentile(upper, 90))
299
+ if r_max <= 1e-8:
300
+ r_max = 1.0
301
+ radii = np.linspace(max(r_min, 1e-3), max(r_max, 1e-3), k)
302
+
303
+ use_minibatch = n > 150
304
+ batch_size = min(5000, (n * (n - 1)) // 2) if use_minibatch else 0
305
+ ema = 0.15
306
+
307
+ for _ in range(n_iter):
308
+ if use_minibatch:
309
+ ii = rng.integers(0, n, size=batch_size)
310
+ jj = rng.integers(0, n, size=batch_size)
311
+ mask = ii != jj
312
+ if not np.any(mask):
313
+ continue
314
+ ii = ii[mask]
315
+ jj = jj[mask]
316
+
317
+ diff = X[ii] - X[jj]
318
+ dist = np.sqrt(np.sum(diff * diff, axis=1))
319
+ dist_safe = np.maximum(dist, 1e-12)
320
+
321
+ assign = np.argmin(np.abs(dist[:, np.newaxis] - radii[np.newaxis, :]), axis=1)
322
+ target = radii[assign]
323
+
324
+ for m in range(k):
325
+ m_mask = assign == m
326
+ if np.any(m_mask):
327
+ radii[m] = (1 - ema) * radii[m] + ema * float(np.mean(dist[m_mask]))
328
+
329
+ err = dist_safe - target
330
+ coef = (2.0 * err / dist_safe)[:, np.newaxis]
331
+ grad_pairs = coef * diff
332
+
333
+ grad = np.zeros_like(X)
334
+ np.add.at(grad, ii, grad_pairs)
335
+ np.add.at(grad, jj, -grad_pairs)
336
+ else:
337
+ D = self._compute_pairwise_distances(X)
338
+ iu, ju = np.triu_indices(n, k=1)
339
+ dist = D[iu, ju]
340
+ dist_safe = np.maximum(dist, 1e-12)
341
+
342
+ assign = np.argmin(np.abs(dist[:, np.newaxis] - radii[np.newaxis, :]), axis=1)
343
+ target = radii[assign]
344
+
345
+ for m in range(k):
346
+ m_mask = assign == m
347
+ if np.any(m_mask):
348
+ radii[m] = float(np.mean(dist[m_mask]))
349
+
350
+ err = dist_safe - target
351
+ coef = (2.0 * err / dist_safe)[:, np.newaxis]
352
+ diff = X[iu] - X[ju]
353
+ grad_pairs = coef * diff
354
+
355
+ grad = np.zeros_like(X)
356
+ np.add.at(grad, iu, grad_pairs)
357
+ np.add.at(grad, ju, -grad_pairs)
358
+
359
+ grad += 1e-3 * X
360
+ X = X - lr * grad
361
+ X = X - X.mean(axis=0, keepdims=True)
362
+
363
+ return X
364
+
365
+ def _compute_pairwise_distances(self, X):
366
+ """Compute pairwise Euclidean distances"""
367
+ n = X.shape[0]
368
+ distances = np.zeros((n, n))
369
+ for i in range(n):
370
+ for j in range(i+1, n):
371
+ dist = np.linalg.norm(X[i] - X[j])
372
+ distances[i, j] = dist
373
+ distances[j, i] = dist
374
+ return distances
375
+
376
+ # ==================== MNIST Dataset ====================
377
+
378
+ def load_mnist(self, max_samples=1000, subset='train'):
379
+ """Load MNIST dataset"""
380
+ try:
381
+ if fetch_openml is None:
382
+ return {'success': False, 'error': 'scikit-learn not available'}
383
+
384
+ mnist = fetch_openml('mnist_784', version=1, as_frame=False, parser='auto')
385
+
386
+ all_images = np.array(mnist.data, dtype=np.float32)
387
+
388
+ if isinstance(mnist.target[0], str):
389
+ all_labels = np.array([int(label) for label in mnist.target], dtype=np.int32)
390
+ else:
391
+ all_labels = np.array(mnist.target, dtype=np.int32)
392
+
393
+ if subset == 'train':
394
+ images_flat = all_images[:60000]
395
+ labels = all_labels[:60000]
396
+ else:
397
+ images_flat = all_images[60000:]
398
+ labels = all_labels[60000:]
399
+
400
+ if max_samples > 0 and max_samples < len(images_flat):
401
+ images_flat = images_flat[:max_samples]
402
+ labels = labels[:max_samples]
403
+
404
+ X = images_flat / 255.0
405
+
406
+ return {
407
+ 'success': True,
408
+ 'X': X,
409
+ 'labels': labels,
410
+ 'count': len(images_flat),
411
+ 'shape': X.shape,
412
+ 'message': f'Loaded {len(images_flat)} MNIST {subset} samples'
413
+ }
414
+
415
+ except Exception as e:
416
+ return {'success': False, 'error': str(e)}
417
+
418
+ # ==================== t-SNE Implementation ====================
419
+
420
+ def run_tsne(self, X, perplexity=30, learning_rate=200, n_iter=1000,
421
+ early_exaggeration=12, momentum=0.8, seed=42, progress_callback=None):
422
+ """Run t-SNE with transparent internals"""
423
+ try:
424
+ n, d = X.shape
425
+
426
+ if n > 1000:
427
+ return {'success': False, 'error': f'Dataset too large ({n} points). Please use n <= 1000.'}
428
+
429
+ # Initialize Y
430
+ np.random.seed(seed)
431
+ Y = np.random.randn(n, 2) * 0.0001
432
+
433
+ # Compute P
434
+ if progress_callback:
435
+ progress_callback(0, 'Computing P matrix...')
436
+ P = self._compute_P(X, perplexity)
437
+
438
+ # Optimize
439
+ if progress_callback:
440
+ progress_callback(0, 'Starting t-SNE optimization...')
441
+ Y, Q, C_history = self._optimize_tsne(
442
+ P, Y, learning_rate, n_iter, early_exaggeration, momentum, progress_callback
443
+ )
444
+
445
+ return {
446
+ 'success': True,
447
+ 'Y': Y,
448
+ 'P': P,
449
+ 'Q': Q,
450
+ 'C_history': C_history,
451
+ 'n': n
452
+ }
453
+
454
+ except Exception as e:
455
+ return {'success': False, 'error': str(e)}
456
+
457
+ def _compute_P(self, X, perplexity):
458
+ """Compute pairwise affinities P_ij"""
459
+ n = X.shape[0]
460
+
461
+ sum_X = np.sum(X**2, axis=1)
462
+ D = sum_X[:, np.newaxis] + sum_X[np.newaxis, :] - 2 * X @ X.T
463
+ D = np.maximum(D, 0)
464
+
465
+ P = np.zeros((n, n))
466
+ target_entropy = np.log2(perplexity)
467
+
468
+ for i in range(n):
469
+ beta_min = -np.inf
470
+ beta_max = np.inf
471
+ beta = 1.0
472
+
473
+ for _ in range(50):
474
+ Di = D[i].copy()
475
+ Di[i] = 0
476
+
477
+ P_i = np.exp(-Di * beta)
478
+ P_i[i] = 0
479
+ sum_P_i = np.sum(P_i)
480
+
481
+ if sum_P_i == 0:
482
+ P_i = np.ones(n) / n
483
+ sum_P_i = 1.0
484
+
485
+ P_i = P_i / sum_P_i
486
+
487
+ P_i_nonzero = P_i[P_i > 1e-12]
488
+ H = -np.sum(P_i_nonzero * np.log2(P_i_nonzero))
489
+
490
+ H_diff = H - target_entropy
491
+ if np.abs(H_diff) < 1e-5:
492
+ break
493
+
494
+ if H_diff > 0:
495
+ beta_min = beta
496
+ if beta_max == np.inf:
497
+ beta = beta * 2
498
+ else:
499
+ beta = (beta + beta_max) / 2
500
+ else:
501
+ beta_max = beta
502
+ if beta_min == -np.inf:
503
+ beta = beta / 2
504
+ else:
505
+ beta = (beta + beta_min) / 2
506
+
507
+ P[i] = P_i
508
+
509
+ P = (P + P.T) / (2 * n)
510
+ P = np.maximum(P, 1e-12)
511
+
512
+ return P
513
+
514
+ def _optimize_tsne(self, P, Y, learning_rate, n_iter, early_exaggeration, momentum, progress_callback=None):
515
+ """Optimize t-SNE using gradient descent"""
516
+ n = Y.shape[0]
517
+ Y_velocity = np.zeros_like(Y)
518
+ C_history = []
519
+
520
+ P_exag = P * early_exaggeration
521
+
522
+ for iteration in range(n_iter):
523
+ P_current = P_exag if iteration < 250 else P
524
+
525
+ sum_Y = np.sum(Y**2, axis=1)
526
+ D_low = sum_Y[:, np.newaxis] + sum_Y[np.newaxis, :] - 2 * Y @ Y.T
527
+ D_low = np.maximum(D_low, 0)
528
+
529
+ Q = (1 + D_low) ** (-1)
530
+ np.fill_diagonal(Q, 0)
531
+ sum_Q = np.sum(Q)
532
+ if sum_Q < 1e-12:
533
+ sum_Q = 1e-12
534
+ Q = Q / sum_Q
535
+ Q = np.maximum(Q, 1e-12)
536
+
537
+ C = np.sum(P_current * np.log((P_current + 1e-12) / (Q + 1e-12)))
538
+ C_history.append(float(C))
539
+
540
+ PQ_diff = P_current - Q
541
+ repulsion = (1 + D_low) ** (-1)
542
+ attraction_repulsion = (PQ_diff * repulsion)[:, :, np.newaxis]
543
+ Y_diff = Y[:, np.newaxis, :] - Y[np.newaxis, :, :]
544
+ gradient = 4 * (attraction_repulsion * Y_diff).sum(axis=1)
545
+
546
+ Y_velocity = momentum * Y_velocity - learning_rate * gradient
547
+ Y = Y + Y_velocity
548
+ Y = Y - Y.mean(axis=0)
549
+
550
+ if progress_callback and iteration % 10 == 0:
551
+ progress_callback(iteration / n_iter, f'Iteration {iteration}/{n_iter}, Cost: {C:.4f}')
552
+
553
+ # Final Q computation
554
+ sum_Y = np.sum(Y**2, axis=1)
555
+ D_low = sum_Y[:, np.newaxis] + sum_Y[np.newaxis, :] - 2 * Y @ Y.T
556
+ D_low = np.maximum(D_low, 0)
557
+ Q = (1 + D_low) ** (-1)
558
+ np.fill_diagonal(Q, 0)
559
+ sum_Q = np.sum(Q)
560
+ if sum_Q < 1e-12:
561
+ sum_Q = 1e-12
562
+ Q = Q / sum_Q
563
+ Q = np.maximum(Q, 1e-12)
564
+
565
+ if progress_callback:
566
+ progress_callback(1.0, 'Complete!')
567
+
568
+ return Y, Q, C_history
569
+
570
+ # ==================== Clustering ====================
571
+
572
+ def run_clustering(self, Y, method='kmeans', k=3, eps=0.5, min_samples=5):
573
+ """Run clustering on t-SNE results"""
574
+ try:
575
+ if method == 'kmeans':
576
+ labels = self._kmeans(Y, k)
577
+ elif method == 'dbscan':
578
+ labels = self._dbscan(Y, eps, min_samples)
579
+ else:
580
+ return {'success': False, 'error': 'Unknown clustering method'}
581
+
582
+ unique_labels = np.unique(labels)
583
+ summary = []
584
+ for label in unique_labels:
585
+ count = np.sum(labels == label)
586
+ summary.append({
587
+ 'label': int(label),
588
+ 'count': int(count)
589
+ })
590
+
591
+ return {
592
+ 'success': True,
593
+ 'labels': labels.tolist(),
594
+ 'summary': summary
595
+ }
596
+
597
+ except Exception as e:
598
+ return {'success': False, 'error': str(e)}
599
+
600
+ def _kmeans(self, X, k, max_iter=100):
601
+ """K-means clustering"""
602
+ n = X.shape[0]
603
+ indices = np.random.choice(n, k, replace=False)
604
+ centroids = X[indices].copy()
605
+ labels = np.zeros(n, dtype=int)
606
+
607
+ for _ in range(max_iter):
608
+ distances = np.zeros((n, k))
609
+ for i in range(k):
610
+ distances[:, i] = np.sum((X - centroids[i])**2, axis=1)
611
+
612
+ new_labels = np.argmin(distances, axis=1)
613
+
614
+ if np.all(labels == new_labels):
615
+ break
616
+
617
+ labels = new_labels
618
+
619
+ for i in range(k):
620
+ cluster_points = X[labels == i]
621
+ if len(cluster_points) > 0:
622
+ centroids[i] = cluster_points.mean(axis=0)
623
+
624
+ return labels
625
+
626
+ def _dbscan(self, X, eps, min_samples):
627
+ """DBSCAN clustering"""
628
+ n = X.shape[0]
629
+ labels = -np.ones(n, dtype=int)
630
+ cluster_id = 0
631
+
632
+ for i in range(n):
633
+ if labels[i] != -1:
634
+ continue
635
+
636
+ neighbors = self._find_neighbors(X, i, eps)
637
+
638
+ if len(neighbors) < min_samples:
639
+ labels[i] = -1
640
+ else:
641
+ self._expand_cluster(X, labels, i, neighbors, cluster_id, eps, min_samples)
642
+ cluster_id += 1
643
+
644
+ return labels
645
+
646
+ def _find_neighbors(self, X, point_idx, eps):
647
+ """Find neighbors within eps distance"""
648
+ distances = np.sum((X - X[point_idx])**2, axis=1)
649
+ return np.where(distances <= eps**2)[0]
650
+
651
+ def _expand_cluster(self, X, labels, point_idx, neighbors, cluster_id, eps, min_samples):
652
+ """Expand cluster from seed point"""
653
+ labels[point_idx] = cluster_id
654
+
655
+ i = 0
656
+ while i < len(neighbors):
657
+ neighbor_idx = neighbors[i]
658
+
659
+ if labels[neighbor_idx] == -1:
660
+ labels[neighbor_idx] = cluster_id
661
+
662
+ if labels[neighbor_idx] != -1:
663
+ i += 1
664
+ continue
665
+
666
+ labels[neighbor_idx] = cluster_id
667
+
668
+ new_neighbors = self._find_neighbors(X, neighbor_idx, eps)
669
+ if len(new_neighbors) >= min_samples:
670
+ neighbors = np.concatenate([neighbors, new_neighbors])
671
+
672
+ i += 1
673
+
674
+
675
+ # ==================== Streamlit UI ====================
676
+
677
+ def main():
678
+ # Page config
679
+ st.set_page_config(
680
+ page_title="t-SNE Explorer",
681
+ page_icon="📊",
682
+ layout="wide",
683
+ initial_sidebar_state="expanded"
684
+ )
685
+
686
+ # Inject custom CSS
687
+ inject_custom_css()
688
+
689
+ # Header
690
+ st.markdown("""
691
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
692
+ padding: 40px; text-align: center; border-radius: 16px; margin-bottom: 20px;">
693
+ <h1 style="color: white; font-size: 3em; margin-bottom: 10px;">t-SNE Explorer</h1>
694
+ <p style="color: white; font-size: 1.2em; opacity: 0.95;">
695
+ Transparent t-SNE with synthetic data generation and file uploads
696
+ </p>
697
+ </div>
698
+ """, unsafe_allow_html=True)
699
+
700
+ # Initialize backend
701
+ if 'backend' not in st.session_state:
702
+ st.session_state.backend = TSNEExplorer()
703
+
704
+ # Initialize session state
705
+ if 'datasets' not in st.session_state:
706
+ st.session_state.datasets = {}
707
+ if 'current_results' not in st.session_state:
708
+ st.session_state.current_results = None
709
+
710
+ # Sidebar navigation
711
+ st.sidebar.title("Navigation")
712
+ tab = st.sidebar.radio("Select Section", ["t-SNE", "Upload"])
713
+
714
+ if tab == "t-SNE":
715
+ tsne_tab()
716
+ else:
717
+ upload_tab()
718
+
719
+
720
+ def tsne_tab():
721
+ """Main t-SNE tab"""
722
+ st.header("t-SNE Analysis")
723
+
724
+ # Section A: Synthetic Data Generator
725
+ with st.expander("A) Synthetic Data Generator", expanded=True):
726
+ st.info("Generate n points in d dimensions with k distinct distance types. "
727
+ "Optimal: k=1 (n≤d+1 simplex), k=2 (n=5 pentagon), k=3 (n=7 heptagon).")
728
+
729
+ col1, col2, col3, col4 = st.columns(4)
730
+ with col1:
731
+ n = st.number_input("n (points)", min_value=1, max_value=100, value=6)
732
+ with col2:
733
+ d = st.number_input("d (dimensions)", min_value=1, max_value=100, value=10)
734
+ with col3:
735
+ k = st.number_input("k (distance types)", min_value=1, value=2)
736
+ with col4:
737
+ seed = st.number_input("seed", min_value=0, value=42)
738
+
739
+ if st.button("Generate Points", key="gen_points"):
740
+ with st.spinner("Generating synthetic data..."):
741
+ result = st.session_state.backend.generate_simplex_points(n, d, k, seed)
742
+
743
+ if result['success']:
744
+ # Store dataset
745
+ dataset_id = f"synthetic_{len(st.session_state.datasets)}"
746
+ st.session_state.datasets[dataset_id] = {
747
+ 'type': 'synthetic',
748
+ 'X': np.array(result['points']),
749
+ 'shape': (result['n'], result['d'])
750
+ }
751
+
752
+ # Display stats
753
+ st.success(f"Generated {result['n']} points successfully!")
754
+
755
+ col1, col2, col3, col4 = st.columns(4)
756
+ col1.metric("Points", result['n'])
757
+ col2.metric("Dimensions", result['d'])
758
+ col3.metric("Target k", result['k'])
759
+ col4.metric("Actual k", result['actual_k'])
760
+
761
+ st.write(f"**Unique Distances:** {', '.join([f'{d:.4f}' for d in result['unique_distances']])}")
762
+ st.write(f"**Range:** min={result['distances_min']:.4f}, "
763
+ f"mean={result['distances_mean']:.4f}, max={result['distances_max']:.4f}")
764
+
765
+ # Display points table
766
+ points_df = pd.DataFrame(
767
+ result['points'],
768
+ columns=[f'x{i+1}' for i in range(result['d'])]
769
+ )
770
+ st.dataframe(points_df.head(10), use_container_width=True)
771
+ else:
772
+ st.error(result['error'])
773
+
774
+ # Section B: MNIST Loader
775
+ with st.expander("B) Load MNIST Dataset"):
776
+ col1, col2 = st.columns(2)
777
+ with col1:
778
+ subset = st.selectbox("Subset", ["train", "test"])
779
+ with col2:
780
+ max_samples = st.number_input("Samples", min_value=100, max_value=10000, value=1000, step=100)
781
+
782
+ if st.button("Load MNIST", key="load_mnist"):
783
+ with st.spinner("Loading MNIST dataset..."):
784
+ progress_bar = st.progress(0)
785
+ progress_bar.progress(0.3)
786
+
787
+ result = st.session_state.backend.load_mnist(max_samples, subset)
788
+ progress_bar.progress(1.0)
789
+
790
+ if result['success']:
791
+ dataset_id = f"mnist_{len(st.session_state.datasets)}"
792
+ st.session_state.datasets[dataset_id] = {
793
+ 'type': 'mnist',
794
+ 'X': result['X'],
795
+ 'labels': result['labels'],
796
+ 'count': result['count']
797
+ }
798
+ st.success(result['message'])
799
+ else:
800
+ st.error(result['error'])
801
+
802
+ # Section C: t-SNE Runner
803
+ with st.expander("C) t-SNE Runner", expanded=True):
804
+ # Dataset selector
805
+ dataset_options = {f"{k} ({v['type']})": k for k, v in st.session_state.datasets.items()}
806
+
807
+ if len(dataset_options) == 0:
808
+ st.warning("No datasets available. Generate synthetic data or load MNIST first.")
809
+ return
810
+
811
+ selected_dataset_key = st.selectbox(
812
+ "Select Dataset",
813
+ options=list(dataset_options.keys())
814
+ )
815
+ selected_dataset_id = dataset_options[selected_dataset_key]
816
+
817
+ # t-SNE parameters
818
+ col1, col2, col3 = st.columns(3)
819
+ with col1:
820
+ perplexity = st.number_input("Perplexity", min_value=5, max_value=50, value=30)
821
+ learning_rate = st.number_input("Learning Rate", min_value=10, max_value=1000, value=200)
822
+ with col2:
823
+ iterations = st.number_input("Iterations", min_value=100, max_value=5000, value=1000)
824
+ early_exag = st.number_input("Early Exaggeration", min_value=1, max_value=50, value=12)
825
+ with col3:
826
+ momentum = st.number_input("Momentum", min_value=0.0, max_value=1.0, value=0.8, step=0.1)
827
+ tsne_seed = st.number_input("Seed", min_value=0, value=42, key="tsne_seed")
828
+
829
+ if st.button("Run t-SNE", key="run_tsne"):
830
+ dataset = st.session_state.datasets[selected_dataset_id]
831
+ X = dataset['X']
832
+
833
+ progress_bar = st.progress(0)
834
+ progress_text = st.empty()
835
+
836
+ def progress_callback(progress, message):
837
+ progress_bar.progress(progress)
838
+ progress_text.text(message)
839
+
840
+ result = st.session_state.backend.run_tsne(
841
+ X, perplexity, learning_rate, iterations,
842
+ early_exag, momentum, tsne_seed, progress_callback
843
+ )
844
+
845
+ if result['success']:
846
+ st.session_state.current_results = result
847
+ st.session_state.current_results['dataset_id'] = selected_dataset_id
848
+ st.session_state.current_results['labels'] = dataset.get('labels')
849
+ st.success("t-SNE completed successfully!")
850
+ st.rerun()
851
+ else:
852
+ st.error(result['error'])
853
+
854
+ # Section D: Results Display
855
+ if st.session_state.current_results:
856
+ display_results()
857
+
858
+
859
+ def display_results():
860
+ """Display t-SNE results"""
861
+ st.header("Results & Internals")
862
+
863
+ results = st.session_state.current_results
864
+ Y = np.array(results['Y'])
865
+ P = np.array(results['P'])
866
+ Q = np.array(results['Q'])
867
+ C_history = results['C_history']
868
+ labels = results.get('labels')
869
+
870
+ # 2D Scatter Plot
871
+ st.subheader("2D t-SNE Embedding")
872
+
873
+ if labels is not None:
874
+ # Color by labels
875
+ fig = go.Figure()
876
+
877
+ unique_labels = np.unique(labels)
878
+ colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6',
879
+ '#1abc9c', '#e67e22', '#95a5a6', '#34495e', '#c0392b']
880
+
881
+ for label in unique_labels:
882
+ mask = labels == label
883
+ fig.add_trace(go.Scatter(
884
+ x=Y[mask, 0],
885
+ y=Y[mask, 1],
886
+ mode='markers',
887
+ name=f'Digit {label}',
888
+ marker=dict(size=8, color=colors[int(label) % len(colors)],
889
+ line=dict(color='white', width=1))
890
+ ))
891
+ else:
892
+ # Default plot
893
+ fig = go.Figure(data=go.Scatter(
894
+ x=Y[:, 0],
895
+ y=Y[:, 1],
896
+ mode='markers+text',
897
+ text=[f'y{i+1}' for i in range(len(Y))],
898
+ textposition='top center',
899
+ marker=dict(size=10, color='#667eea', line=dict(color='white', width=1))
900
+ ))
901
+
902
+ fig.update_layout(
903
+ title="t-SNE Embedding",
904
+ xaxis_title="Dimension 1",
905
+ yaxis_title="Dimension 2",
906
+ height=500
907
+ )
908
+ st.plotly_chart(fig, use_container_width=True)
909
+
910
+ # Cost Plot
911
+ st.subheader("Cost (KL Divergence) Over Iterations")
912
+ fig_cost = go.Figure(data=go.Scatter(
913
+ y=C_history,
914
+ mode='lines',
915
+ line=dict(color='#e74c3c', width=2)
916
+ ))
917
+ fig_cost.update_layout(
918
+ xaxis_title="Iteration",
919
+ yaxis_title="Cost (KL Divergence)",
920
+ height=400
921
+ )
922
+ st.plotly_chart(fig_cost, use_container_width=True)
923
+
924
+ # Matrices
925
+ col1, col2 = st.columns(2)
926
+
927
+ with col1:
928
+ st.subheader("P Matrix (High-D Affinities)")
929
+ fig_p = go.Figure(data=go.Heatmap(z=P, colorscale='Viridis'))
930
+ fig_p.update_layout(height=400)
931
+ st.plotly_chart(fig_p, use_container_width=True)
932
+
933
+ with col2:
934
+ st.subheader("Q Matrix (Low-D Affinities)")
935
+ fig_q = go.Figure(data=go.Heatmap(z=Q, colorscale='Viridis'))
936
+ fig_q.update_layout(height=400)
937
+ st.plotly_chart(fig_q, use_container_width=True)
938
+
939
+ # Coordinates Table
940
+ st.subheader("2D Coordinates")
941
+ coords_df = pd.DataFrame(Y, columns=['Dim 1', 'Dim 2'])
942
+ coords_df.index = [f'y{i+1}' for i in range(len(Y))]
943
+ st.dataframe(coords_df.head(20), use_container_width=True)
944
+
945
+ # Export
946
+ if st.button("Export Results (CSV)"):
947
+ csv = coords_df.to_csv()
948
+ st.download_button(
949
+ label="Download CSV",
950
+ data=csv,
951
+ file_name="tsne_results.csv",
952
+ mime="text/csv"
953
+ )
954
+ st.success("Results exported!")
955
+
956
+ # Clustering section
957
+ if labels is not None:
958
+ st.subheader("Clustering")
959
+ cluster_method = st.selectbox("Method", ["kmeans", "dbscan"])
960
+
961
+ if cluster_method == "kmeans":
962
+ k = st.number_input("k (clusters)", min_value=2, max_value=10, value=3)
963
+ if st.button("Run K-Means"):
964
+ result = st.session_state.backend.run_clustering(Y, 'kmeans', k=k)
965
+ if result['success']:
966
+ st.success("Clustering complete!")
967
+ st.write("**Cluster Summary:**")
968
+ st.json(result['summary'])
969
+ else:
970
+ col1, col2 = st.columns(2)
971
+ with col1:
972
+ eps = st.number_input("eps", min_value=0.1, value=0.5, step=0.1)
973
+ with col2:
974
+ min_samples = st.number_input("min_samples", min_value=1, value=5)
975
+ if st.button("Run DBSCAN"):
976
+ result = st.session_state.backend.run_clustering(Y, 'dbscan', eps=eps, min_samples=min_samples)
977
+ if result['success']:
978
+ st.success("Clustering complete!")
979
+ st.write("**Cluster Summary:**")
980
+ st.json(result['summary'])
981
+
982
+
983
+ def upload_tab():
984
+ """Upload tab for CSV and images"""
985
+ st.header("Upload Data")
986
+
987
+ st.subheader("CSV Files")
988
+ uploaded_csv = st.file_uploader("Upload CSV", type=['csv'], accept_multiple_files=False)
989
+
990
+ if uploaded_csv:
991
+ try:
992
+ df = pd.read_csv(uploaded_csv)
993
+
994
+ dataset_id = f"csv_{len(st.session_state.datasets)}"
995
+ numeric_columns = df.select_dtypes(include=[np.number]).columns.tolist()
996
+
997
+ st.success(f"Uploaded {uploaded_csv.name}")
998
+ st.write(f"Shape: {df.shape}")
999
+ st.write(f"Numeric columns: {', '.join(numeric_columns)}")
1000
+
1001
+ st.dataframe(df.head())
1002
+
1003
+ # Column selector
1004
+ selected_cols = st.multiselect("Select numeric columns", numeric_columns, default=numeric_columns)
1005
+ handle_missing = st.selectbox("Handle missing values", ["drop", "mean", "zero"])
1006
+
1007
+ if st.button("Prepare Dataset"):
1008
+ if selected_cols:
1009
+ df_subset = df[selected_cols]
1010
+
1011
+ if handle_missing == 'drop':
1012
+ df_subset = df_subset.dropna()
1013
+ elif handle_missing == 'mean':
1014
+ df_subset = df_subset.fillna(df_subset.mean())
1015
+ elif handle_missing == 'zero':
1016
+ df_subset = df_subset.fillna(0)
1017
+
1018
+ X = df_subset.values
1019
+
1020
+ st.session_state.datasets[dataset_id] = {
1021
+ 'type': 'csv',
1022
+ 'X': X,
1023
+ 'shape': X.shape,
1024
+ 'name': uploaded_csv.name
1025
+ }
1026
+
1027
+ st.success(f"Dataset prepared: {X.shape[0]} rows × {X.shape[1]} columns")
1028
+ else:
1029
+ st.warning("Please select at least one column")
1030
+
1031
+ except Exception as e:
1032
+ st.error(f"Error uploading CSV: {str(e)}")
1033
+
1034
+ # Dataset list
1035
+ st.subheader("Uploaded Datasets")
1036
+ if len(st.session_state.datasets) == 0:
1037
+ st.info("No datasets uploaded yet")
1038
+ else:
1039
+ for dataset_id, dataset in st.session_state.datasets.items():
1040
+ if dataset['type'] == 'synthetic':
1041
+ st.write(f"🔢 Synthetic: {dataset['shape'][0]}×{dataset['shape'][1]}")
1042
+ elif dataset['type'] == 'csv':
1043
+ st.write(f"📊 CSV: {dataset.get('name', 'Unknown')} ({dataset['shape'][0]}×{dataset['shape'][1]})")
1044
+ elif dataset['type'] == 'mnist':
1045
+ st.write(f"✏️ MNIST: {dataset['count']} samples")
1046
+
1047
+
1048
+ if __name__ == '__main__':
1049
+ main()
web/api-adapter.js ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * API Adapter for Flask Backend (android.py)
3
+ *
4
+ * This file provides a pywebview.api compatible interface that works with
5
+ * the Flask REST API instead of the pywebview Python bridge.
6
+ *
7
+ * Include this BEFORE app.js when running on Android/Flask:
8
+ * <script src="api-adapter.js"></script>
9
+ * <script src="app.js"></script>
10
+ */
11
+
12
+ (function() {
13
+ 'use strict';
14
+
15
+ // API base URL - will be automatically set to current origin
16
+ const API_BASE = window.location.origin;
17
+
18
+ /**
19
+ * Make API call to Flask backend
20
+ */
21
+ async function apiCall(endpoint, data = null) {
22
+ const url = `${API_BASE}/api/${endpoint}`;
23
+
24
+ const options = {
25
+ method: data ? 'POST' : 'GET',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ }
29
+ };
30
+
31
+ if (data) {
32
+ options.body = JSON.stringify(data);
33
+ }
34
+
35
+ try {
36
+ const response = await fetch(url, options);
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`HTTP error! status: ${response.status}`);
40
+ }
41
+
42
+ const result = await response.json();
43
+ return result;
44
+ } catch (error) {
45
+ console.error(`API call failed: ${endpoint}`, error);
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Create pywebview.api compatible interface
52
+ */
53
+ window.pywebview = {
54
+ api: {
55
+ // ==================== Synthetic Data Generation ====================
56
+
57
+ generate_simplex_points: async function(n, d, k, seed) {
58
+ return await apiCall('generate_simplex_points', { n, d, k, seed });
59
+ },
60
+
61
+ save_synthetic_dataset: async function(points) {
62
+ return await apiCall('save_synthetic_dataset', { points });
63
+ },
64
+
65
+ // ==================== MNIST Dataset ====================
66
+
67
+ load_mnist: async function(max_samples, subset) {
68
+ return await apiCall('load_mnist', { max_samples, subset });
69
+ },
70
+
71
+ // ==================== Upload Handling ====================
72
+
73
+ upload_csv: async function(name, content, delimiter) {
74
+ return await apiCall('upload_csv', { name, content, delimiter });
75
+ },
76
+
77
+ upload_images: async function(files) {
78
+ return await apiCall('upload_images', { files });
79
+ },
80
+
81
+ list_datasets: async function() {
82
+ return await apiCall('list_datasets');
83
+ },
84
+
85
+ prepare_csv_dataset: async function(dataset_id, selected_columns, handle_missing) {
86
+ return await apiCall('prepare_csv_dataset', {
87
+ dataset_id,
88
+ selected_columns,
89
+ handle_missing
90
+ });
91
+ },
92
+
93
+ // ==================== Embeddings ====================
94
+
95
+ compute_embeddings: async function(dataset_id, method) {
96
+ return await apiCall('compute_embeddings', { dataset_id, method });
97
+ },
98
+
99
+ // ==================== t-SNE ====================
100
+
101
+ run_tsne: async function(dataset_id, perplexity, learning_rate, n_iter,
102
+ early_exaggeration, momentum, init_method, init_data, seed) {
103
+ return await apiCall('run_tsne', {
104
+ dataset_id,
105
+ perplexity,
106
+ learning_rate,
107
+ n_iter,
108
+ early_exaggeration,
109
+ momentum,
110
+ init_method,
111
+ init_data,
112
+ seed
113
+ });
114
+ },
115
+
116
+ stop_tsne: async function() {
117
+ // Note: stop functionality needs to be implemented in Flask backend
118
+ return { success: true };
119
+ },
120
+
121
+ // ==================== Clustering ====================
122
+
123
+ run_clustering: async function(dataset_id, method, k, eps, min_samples) {
124
+ return await apiCall('run_clustering', {
125
+ dataset_id,
126
+ method,
127
+ k,
128
+ eps,
129
+ min_samples
130
+ });
131
+ },
132
+
133
+ // ==================== Export ====================
134
+
135
+ export_results: async function(dataset_id) {
136
+ return await apiCall('export_results', { dataset_id });
137
+ },
138
+
139
+ get_image_at_index: async function(dataset_id, index) {
140
+ return await apiCall('get_image_at_index', { dataset_id, index });
141
+ }
142
+ },
143
+
144
+ // ==================== API Status ====================
145
+
146
+ /**
147
+ * Check if we're running in Flask/Android mode
148
+ */
149
+ isFlaskMode: function() {
150
+ return true;
151
+ },
152
+
153
+ /**
154
+ * Check MCP connection status
155
+ */
156
+ checkMCPStatus: async function() {
157
+ try {
158
+ const status = await apiCall('mcp_status');
159
+ return status;
160
+ } catch (error) {
161
+ return { connected: false, available: false };
162
+ }
163
+ }
164
+ };
165
+
166
+ // ==================== Progress Updates ====================
167
+
168
+ /**
169
+ * Progress updates for t-SNE
170
+ * In Flask mode, we use polling instead of callbacks
171
+ */
172
+ let progressInterval = null;
173
+
174
+ window.startProgressPolling = function() {
175
+ if (progressInterval) {
176
+ clearInterval(progressInterval);
177
+ }
178
+
179
+ // Poll for progress updates every 500ms
180
+ progressInterval = setInterval(async () => {
181
+ try {
182
+ const progress = await apiCall('tsne_progress');
183
+ if (progress && progress.current !== undefined) {
184
+ window.updateProgress(progress.current, progress.total, progress.message);
185
+
186
+ // Stop polling when complete
187
+ if (progress.current >= progress.total) {
188
+ clearInterval(progressInterval);
189
+ progressInterval = null;
190
+ }
191
+ }
192
+ } catch (error) {
193
+ // Silently fail if progress endpoint not available
194
+ }
195
+ }, 500);
196
+ };
197
+
198
+ window.stopProgressPolling = function() {
199
+ if (progressInterval) {
200
+ clearInterval(progressInterval);
201
+ progressInterval = null;
202
+ }
203
+ };
204
+
205
+ // ==================== Connection Status Indicator ====================
206
+
207
+ /**
208
+ * Show connection status in UI
209
+ */
210
+ async function showConnectionStatus() {
211
+ try {
212
+ const health = await apiCall('health');
213
+ const statusDiv = document.createElement('div');
214
+ statusDiv.id = 'connection-status';
215
+ statusDiv.style.cssText = `
216
+ position: fixed;
217
+ top: 10px;
218
+ right: 10px;
219
+ padding: 8px 12px;
220
+ background: ${health.mcp_connected ? '#10b981' : '#f59e0b'};
221
+ color: white;
222
+ border-radius: 6px;
223
+ font-size: 0.85em;
224
+ font-weight: 600;
225
+ z-index: 9999;
226
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
227
+ `;
228
+ statusDiv.innerHTML = health.mcp_connected
229
+ ? '🟢 MCP Connected'
230
+ : '🟡 Local Mode';
231
+
232
+ document.body.appendChild(statusDiv);
233
+
234
+ // Add tooltip
235
+ statusDiv.title = health.mcp_connected
236
+ ? 'Connected to MCP server for heavy computations'
237
+ : 'Using local fallback (computations may be slower)';
238
+
239
+ } catch (error) {
240
+ console.error('Failed to check connection status:', error);
241
+ }
242
+ }
243
+
244
+ // ==================== Initialization ====================
245
+
246
+ /**
247
+ * Initialize Flask API adapter
248
+ */
249
+ function initAdapter() {
250
+ console.log('Flask API Adapter initialized');
251
+ console.log(`API Base URL: ${API_BASE}`);
252
+
253
+ // Show connection status
254
+ showConnectionStatus();
255
+
256
+ // Dispatch ready event
257
+ window.dispatchEvent(new Event('pywebviewready'));
258
+ }
259
+
260
+ // Wait for DOM to be ready
261
+ if (document.readyState === 'loading') {
262
+ document.addEventListener('DOMContentLoaded', initAdapter);
263
+ } else {
264
+ initAdapter();
265
+ }
266
+
267
+ // ==================== Helper Functions ====================
268
+
269
+ /**
270
+ * Download file helper (for CSV export)
271
+ */
272
+ window.downloadFile = function(content, filename, type) {
273
+ const blob = new Blob([content], { type: type });
274
+ const url = URL.createObjectURL(blob);
275
+ const a = document.createElement('a');
276
+ a.href = url;
277
+ a.download = filename;
278
+ a.click();
279
+ URL.revokeObjectURL(url);
280
+ };
281
+
282
+ })();
web/app.js ADDED
@@ -0,0 +1,1253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Global state
2
+ let currentDatasetId = null;
3
+ let currentResults = null;
4
+ let uploadedDatasets = [];
5
+
6
+ // Check if pywebview is available
7
+ function ensureAPI() {
8
+ if (typeof pywebview === 'undefined' || !pywebview.api) {
9
+ throw new Error('PyWebView API not available. Please ensure the app is running in pywebview.');
10
+ }
11
+ }
12
+
13
+ // Wait for DOM and pywebview to be ready
14
+ function init() {
15
+ console.log('Initializing t-SNE Explorer...');
16
+
17
+ // Setup tab switching
18
+ setupTabs();
19
+
20
+ // Setup all event listeners with try-catch
21
+ setupSyntheticDataGenerator();
22
+ setupDataSourceManagement();
23
+ setupTSNERunner();
24
+ setupClustering();
25
+ setupExport();
26
+ setupUpload();
27
+ setupModal();
28
+
29
+ // Load initial data
30
+ safeAPICall(async () => {
31
+ await updateDataSourceDropdown();
32
+ await refreshDatasetList();
33
+ });
34
+ }
35
+
36
+ // Safe API call wrapper
37
+ async function safeAPICall(fn, errorMsg = 'An error occurred') {
38
+ try {
39
+ ensureAPI();
40
+ return await fn();
41
+ } catch (error) {
42
+ console.error(errorMsg, error);
43
+ showNotification(errorMsg + ': ' + error.message, 'error');
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // Notification system
49
+ function showNotification(message, type = 'info') {
50
+ // Create notification element if it doesn't exist
51
+ let notif = document.getElementById('notification');
52
+ if (!notif) {
53
+ notif = document.createElement('div');
54
+ notif.id = 'notification';
55
+ document.body.appendChild(notif);
56
+ }
57
+
58
+ notif.textContent = message;
59
+ notif.className = `notification ${type} show`;
60
+
61
+ setTimeout(() => {
62
+ notif.classList.remove('show');
63
+ }, 4000);
64
+ }
65
+
66
+ // ==================== Tab Management ====================
67
+
68
+ function setupTabs() {
69
+ document.querySelectorAll('.tab-button').forEach(button => {
70
+ button.addEventListener('click', () => {
71
+ const tabId = button.dataset.tab;
72
+
73
+ // Update button states
74
+ document.querySelectorAll('.tab-button').forEach(b => b.classList.remove('active'));
75
+ button.classList.add('active');
76
+
77
+ // Update tab content
78
+ document.querySelectorAll('.tab-content').forEach(content => {
79
+ content.classList.remove('active');
80
+ });
81
+ document.getElementById(tabId).classList.add('active');
82
+ });
83
+ });
84
+ }
85
+
86
+ // ==================== Synthetic Data Generation ====================
87
+
88
+ function setupSyntheticDataGenerator() {
89
+ const generateBtn = document.getElementById('generate-btn');
90
+ if (!generateBtn) return;
91
+
92
+ generateBtn.addEventListener('click', async () => {
93
+ const n = parseInt(document.getElementById('synth-n').value);
94
+ const d = parseInt(document.getElementById('synth-d').value);
95
+ const k = parseFloat(document.getElementById('synth-k').value);
96
+ const seed = parseInt(document.getElementById('synth-seed').value);
97
+
98
+ const result = await safeAPICall(
99
+ async () => await pywebview.api.generate_simplex_points(n, d, k, seed),
100
+ 'Error generating synthetic data'
101
+ );
102
+
103
+ if (!result) return;
104
+
105
+ if (!result.success) {
106
+ showNotification(result.error, 'error');
107
+ return;
108
+ }
109
+
110
+ // Display results
111
+ const output = document.getElementById('synth-output');
112
+ output.classList.remove('hidden');
113
+
114
+ const stats = document.getElementById('synth-stats');
115
+ stats.innerHTML = `
116
+ <div class="stats-grid">
117
+ <div class="stat-card">
118
+ <div class="stat-label">Points</div>
119
+ <div class="stat-value">${result.n}</div>
120
+ </div>
121
+ <div class="stat-card">
122
+ <div class="stat-label">Dimensions</div>
123
+ <div class="stat-value">${result.d}</div>
124
+ </div>
125
+ <div class="stat-card">
126
+ <div class="stat-label">Target k</div>
127
+ <div class="stat-value">${result.k}</div>
128
+ </div>
129
+ <div class="stat-card">
130
+ <div class="stat-label">Actual k</div>
131
+ <div class="stat-value">${result.actual_k}</div>
132
+ </div>
133
+ </div>
134
+ <div class="distance-info">
135
+ <strong>Unique Distance Values:</strong> [${result.unique_distances.map(d => d.toFixed(4)).join(', ')}]
136
+ <br>
137
+ <strong>Range:</strong> min=${result.distances_min.toFixed(4)}, mean=${result.distances_mean.toFixed(4)}, max=${result.distances_max.toFixed(4)}
138
+ </div>
139
+ `;
140
+
141
+ // Display points table
142
+ displayPointsTable(result.points, result.d);
143
+
144
+ // Display distance matrix
145
+ displayDistanceMatrix(result.points);
146
+
147
+ // Save as dataset
148
+ const saveResult = await safeAPICall(
149
+ async () => await pywebview.api.save_synthetic_dataset(result.points)
150
+ );
151
+
152
+ if (saveResult && saveResult.success) {
153
+ currentDatasetId = saveResult.dataset_id;
154
+ await updateDataSourceDropdown();
155
+ await refreshDatasetList();
156
+ showNotification('Synthetic dataset generated successfully!', 'success');
157
+ }
158
+ });
159
+ }
160
+
161
+ function displayPointsTable(points, d) {
162
+ const tableContainer = document.getElementById('synth-table-container');
163
+ const maxRows = Math.min(10, points.length);
164
+
165
+ let tableHTML = '<div class="table-wrapper"><table class="data-table"><thead><tr><th>Point</th>';
166
+ for (let j = 0; j < d; j++) {
167
+ tableHTML += `<th>x<sub>${j+1}</sub></th>`;
168
+ }
169
+ tableHTML += '</tr></thead><tbody>';
170
+
171
+ for (let i = 0; i < maxRows; i++) {
172
+ tableHTML += `<tr><td>x<sub>${i+1}</sub></td>`;
173
+ for (let j = 0; j < d; j++) {
174
+ tableHTML += `<td>${points[i][j].toFixed(4)}</td>`;
175
+ }
176
+ tableHTML += '</tr>';
177
+ }
178
+
179
+ if (points.length > 10) {
180
+ tableHTML += `<tr><td colspan="${d + 1}" class="more-rows">... (${points.length - 10} more rows)</td></tr>`;
181
+ }
182
+
183
+ tableHTML += '</tbody></table></div>';
184
+ tableContainer.innerHTML = tableHTML;
185
+ }
186
+
187
+ function displayDistanceMatrix(points) {
188
+ const distContainer = document.getElementById('synth-distances-container');
189
+ const n = points.length;
190
+
191
+ // Compute pairwise distances
192
+ const distances = [];
193
+ for (let i = 0; i < n; i++) {
194
+ distances[i] = [];
195
+ for (let j = 0; j < n; j++) {
196
+ if (i === j) {
197
+ distances[i][j] = 0;
198
+ } else {
199
+ let sum = 0;
200
+ for (let k = 0; k < points[i].length; k++) {
201
+ sum += (points[i][k] - points[j][k]) ** 2;
202
+ }
203
+ distances[i][j] = Math.sqrt(sum);
204
+ }
205
+ }
206
+ }
207
+
208
+ // Build table HTML
209
+ let tableHTML = '<div class="table-wrapper"><table class="data-table distance-matrix"><thead><tr><th></th>';
210
+ for (let j = 0; j < n; j++) {
211
+ tableHTML += `<th>x<sub>${j+1}</sub></th>`;
212
+ }
213
+ tableHTML += '</tr></thead><tbody>';
214
+
215
+ for (let i = 0; i < n; i++) {
216
+ tableHTML += `<tr><td><strong>x<sub>${i+1}</sub></strong></td>`;
217
+ for (let j = 0; j < n; j++) {
218
+ const cellClass = i === j ? 'diagonal' : '';
219
+ tableHTML += `<td class="${cellClass}">${distances[i][j].toFixed(4)}</td>`;
220
+ }
221
+ tableHTML += '</tr>';
222
+ }
223
+
224
+ tableHTML += '</tbody></table></div>';
225
+ distContainer.innerHTML = tableHTML;
226
+ }
227
+
228
+ // ==================== Data Source Management ====================
229
+
230
+ function setupDataSourceManagement() {
231
+ const dataSource = document.getElementById('data-source');
232
+ if (!dataSource) return;
233
+
234
+ dataSource.addEventListener('change', async (e) => {
235
+ const value = e.target.value;
236
+ currentDatasetId = value === 'synthetic' ? null : value;
237
+
238
+ // Show/hide relevant controls
239
+ document.getElementById('csv-columns-group').style.display = 'none';
240
+ document.getElementById('image-embed-group').style.display = 'none';
241
+ document.getElementById('mnist-load-group').style.display = 'none';
242
+
243
+ if (value === 'load-mnist') {
244
+ // Show MNIST loading controls
245
+ console.log('Showing MNIST load group');
246
+ document.getElementById('mnist-load-group').style.display = 'block';
247
+ currentDatasetId = null;
248
+ } else if (value && value.startsWith('csv_')) {
249
+ document.getElementById('csv-columns-group').style.display = 'block';
250
+ await loadCsvColumns(value);
251
+ } else if (value && value.startsWith('images_')) {
252
+ document.getElementById('image-embed-group').style.display = 'block';
253
+ currentDatasetId = value;
254
+ } else if (value && value.startsWith('mnist_')) {
255
+ // MNIST datasets are ready to use, no preparation needed
256
+ currentDatasetId = value;
257
+ }
258
+ });
259
+
260
+ // Prepare CSV button
261
+ const prepareCsvBtn = document.getElementById('prepare-csv-btn');
262
+ if (prepareCsvBtn) {
263
+ prepareCsvBtn.addEventListener('click', async () => {
264
+ const datasetId = document.getElementById('data-source').value;
265
+ const checkboxes = document.querySelectorAll('#csv-columns-list input:checked');
266
+ const selectedColumns = Array.from(checkboxes).map(cb => cb.value);
267
+ const handleMissing = document.getElementById('csv-missing').value;
268
+
269
+ if (selectedColumns.length === 0) {
270
+ showNotification('Please select at least one column', 'warning');
271
+ return;
272
+ }
273
+
274
+ const result = await safeAPICall(
275
+ async () => await pywebview.api.prepare_csv_dataset(datasetId, selectedColumns, handleMissing)
276
+ );
277
+
278
+ if (result && result.success) {
279
+ showNotification(`Dataset prepared: ${result.shape[0]} rows x ${result.shape[1]} columns`, 'success');
280
+ }
281
+ });
282
+ }
283
+
284
+ // MNIST loading button
285
+ const loadMnistBtn = document.getElementById('load-mnist-btn');
286
+ if (loadMnistBtn) {
287
+ console.log('✓ MNIST button found, attaching click handler');
288
+ loadMnistBtn.addEventListener('click', async () => {
289
+ console.log('MNIST Load button clicked!');
290
+
291
+ const subset = document.getElementById('mnist-subset').value;
292
+ const maxSamples = parseInt(document.getElementById('mnist-samples').value);
293
+ const statusDiv = document.getElementById('mnist-status');
294
+ const progressContainer = document.getElementById('mnist-progress-container');
295
+ const progressBar = document.getElementById('mnist-progress-bar');
296
+ const progressText = document.getElementById('mnist-progress-text');
297
+
298
+ console.log(`Loading MNIST: subset=${subset}, samples=${maxSamples}`);
299
+
300
+ // Show progress bar
301
+ progressContainer.style.display = 'block';
302
+ statusDiv.style.display = 'none';
303
+ loadMnistBtn.disabled = true;
304
+ loadMnistBtn.textContent = 'Loading...';
305
+
306
+ // Simulate progress steps
307
+ const updateProgress = (percent, message) => {
308
+ progressBar.style.width = percent + '%';
309
+ progressText.textContent = message;
310
+ };
311
+
312
+ updateProgress(10, 'Connecting to OpenML...');
313
+ await new Promise(resolve => setTimeout(resolve, 500));
314
+
315
+ updateProgress(30, 'Downloading MNIST dataset...');
316
+
317
+ const result = await safeAPICall(
318
+ async () => await pywebview.api.load_mnist(maxSamples, subset),
319
+ 'Error loading MNIST dataset'
320
+ );
321
+
322
+ console.log('MNIST load result:', result);
323
+
324
+ if (result && result.success) {
325
+ updateProgress(70, 'Processing images...');
326
+ await new Promise(resolve => setTimeout(resolve, 300));
327
+
328
+ updateProgress(90, 'Creating dataset...');
329
+ await new Promise(resolve => setTimeout(resolve, 300));
330
+
331
+ updateProgress(100, 'Complete!');
332
+ await new Promise(resolve => setTimeout(resolve, 500));
333
+
334
+ // Hide progress, show success message
335
+ progressContainer.style.display = 'none';
336
+ statusDiv.style.display = 'block';
337
+ statusDiv.textContent = `✓ ${result.message}`;
338
+ statusDiv.style.color = '#10b981';
339
+ statusDiv.style.background = '#d1fae5';
340
+
341
+ showNotification(result.message, 'success');
342
+ await updateDataSourceDropdown();
343
+ await refreshDatasetList();
344
+
345
+ // Auto-select the newly loaded dataset
346
+ const datasets = await safeAPICall(async () => await pywebview.api.list_datasets());
347
+ if (datasets && datasets.length > 0) {
348
+ const mnistDataset = datasets.find(d => d.type === 'mnist');
349
+ if (mnistDataset) {
350
+ dataSource.value = mnistDataset.id;
351
+ currentDatasetId = mnistDataset.id;
352
+ document.getElementById('mnist-load-group').style.display = 'none';
353
+ }
354
+ }
355
+ } else {
356
+ progressContainer.style.display = 'none';
357
+ statusDiv.style.display = 'block';
358
+ statusDiv.textContent = `✗ Failed to load MNIST: ${result?.error || 'Unknown error'}`;
359
+ statusDiv.style.color = '#ef4444';
360
+ statusDiv.style.background = '#fee2e2';
361
+ }
362
+
363
+ loadMnistBtn.disabled = false;
364
+ loadMnistBtn.textContent = 'Load MNIST Dataset';
365
+ });
366
+ } else {
367
+ console.error('✗ MNIST button NOT found!');
368
+ }
369
+
370
+ // Compute embeddings button
371
+ const computeEmbedBtn = document.getElementById('compute-embed-btn');
372
+ if (computeEmbedBtn) {
373
+ computeEmbedBtn.addEventListener('click', async () => {
374
+ const datasetId = document.getElementById('data-source').value;
375
+ const method = document.getElementById('embed-method').value;
376
+ const statusDiv = document.getElementById('embed-status');
377
+
378
+ statusDiv.textContent = 'Computing embeddings...';
379
+ statusDiv.className = 'embed-status computing';
380
+
381
+ const result = await safeAPICall(
382
+ async () => await pywebview.api.compute_embeddings(datasetId, method)
383
+ );
384
+
385
+ if (result && result.success) {
386
+ statusDiv.textContent = `✓ Embeddings computed using ${result.method}: ${result.shape[0]}x${result.shape[1]}`;
387
+ statusDiv.className = 'embed-status success';
388
+ showNotification('Embeddings computed successfully!', 'success');
389
+ } else {
390
+ statusDiv.textContent = '✗ Failed to compute embeddings';
391
+ statusDiv.className = 'embed-status error';
392
+ }
393
+ });
394
+ }
395
+ }
396
+
397
+ async function updateDataSourceDropdown() {
398
+ const select = document.getElementById('data-source');
399
+ if (!select) return;
400
+
401
+ const datasets = await safeAPICall(async () => await pywebview.api.list_datasets());
402
+ if (!datasets) return;
403
+
404
+ // Clear existing options except first three (includes Load MNIST Dataset)
405
+ while (select.options.length > 3) {
406
+ select.remove(3);
407
+ }
408
+
409
+ // Add dataset options
410
+ datasets.forEach(dataset => {
411
+ const option = document.createElement('option');
412
+ option.value = dataset.id;
413
+
414
+ if (dataset.type === 'csv') {
415
+ option.textContent = `📊 CSV: ${dataset.name} (${dataset.shape[0]}×${dataset.shape[1]})`;
416
+ } else if (dataset.type === 'images') {
417
+ option.textContent = `🖼️ Images: ${dataset.count} files`;
418
+ } else if (dataset.type === 'synthetic') {
419
+ option.textContent = `🔢 Synthetic: ${dataset.shape[0]}×${dataset.shape[1]}`;
420
+ } else if (dataset.type === 'mnist') {
421
+ option.textContent = `✏️ ${dataset.name}`;
422
+ }
423
+
424
+ select.appendChild(option);
425
+
426
+ if (dataset.id === currentDatasetId) {
427
+ select.value = dataset.id;
428
+ }
429
+ });
430
+ }
431
+
432
+ async function loadCsvColumns(datasetId) {
433
+ // This would need a separate API call to get column info
434
+ // For now, it's a placeholder
435
+ }
436
+
437
+ // ==================== t-SNE Runner ====================
438
+
439
+ function setupTSNERunner() {
440
+ const runBtn = document.getElementById('run-tsne-btn');
441
+ const stopBtn = document.getElementById('stop-tsne-btn');
442
+ const initMethodSelect = document.getElementById('init-method');
443
+ const customInitGroup = document.getElementById('custom-init-group');
444
+
445
+ // Handle initialization method change
446
+ if (initMethodSelect && customInitGroup) {
447
+ initMethodSelect.addEventListener('change', (e) => {
448
+ if (e.target.value === 'custom') {
449
+ customInitGroup.style.display = 'block';
450
+ } else {
451
+ customInitGroup.style.display = 'none';
452
+ }
453
+ });
454
+ }
455
+
456
+ if (runBtn) {
457
+ runBtn.addEventListener('click', async () => {
458
+ const datasetId = document.getElementById('data-source').value;
459
+
460
+ if (!datasetId) {
461
+ showNotification('Please select a data source first', 'warning');
462
+ return;
463
+ }
464
+
465
+ const params = {
466
+ perplexity: parseInt(document.getElementById('perplexity').value),
467
+ learning_rate: parseInt(document.getElementById('learning-rate').value),
468
+ n_iter: parseInt(document.getElementById('iterations').value),
469
+ early_exaggeration: parseInt(document.getElementById('early-exag').value),
470
+ momentum: parseFloat(document.getElementById('momentum').value),
471
+ init_method: document.getElementById('init-method').value,
472
+ seed: parseInt(document.getElementById('tsne-seed').value)
473
+ };
474
+
475
+ // Handle custom initialization
476
+ let init_data = null;
477
+ if (params.init_method === 'custom') {
478
+ const customInitText = document.getElementById('custom-init-coords').value.trim();
479
+ if (customInitText) {
480
+ try {
481
+ init_data = JSON.parse(customInitText);
482
+ } catch (e) {
483
+ showNotification('Invalid JSON format for custom initialization', 'error');
484
+ return;
485
+ }
486
+ } else {
487
+ showNotification('Please provide custom initialization coordinates', 'warning');
488
+ return;
489
+ }
490
+ }
491
+
492
+ // Show progress
493
+ const progressContainer = document.getElementById('progress-container');
494
+ progressContainer.classList.remove('hidden');
495
+ runBtn.style.display = 'none';
496
+ stopBtn.style.display = 'inline-block';
497
+
498
+ const result = await safeAPICall(
499
+ async () => await pywebview.api.run_tsne(
500
+ datasetId,
501
+ params.perplexity,
502
+ params.learning_rate,
503
+ params.n_iter,
504
+ params.early_exaggeration,
505
+ params.momentum,
506
+ params.init_method,
507
+ init_data,
508
+ params.seed
509
+ ),
510
+ 'Error running t-SNE'
511
+ );
512
+
513
+ progressContainer.classList.add('hidden');
514
+ runBtn.style.display = 'inline-block';
515
+ stopBtn.style.display = 'none';
516
+
517
+ if (result && result.success) {
518
+ currentResults = result;
519
+ displayResults(result, datasetId);
520
+
521
+ // Show clustering section for image/MNIST datasets
522
+ const clusteringSection = document.getElementById('clustering-section');
523
+ const isImageDataset = datasetId && (datasetId.startsWith('images_') || datasetId.startsWith('mnist_'));
524
+
525
+ if (clusteringSection) {
526
+ if (isImageDataset) {
527
+ clusteringSection.style.display = 'block';
528
+ } else {
529
+ clusteringSection.style.display = 'none';
530
+ }
531
+ }
532
+
533
+ showNotification('t-SNE completed successfully!', 'success');
534
+ }
535
+ });
536
+ }
537
+
538
+ if (stopBtn) {
539
+ stopBtn.addEventListener('click', async () => {
540
+ await safeAPICall(async () => await pywebview.api.stop_tsne());
541
+ showNotification('t-SNE stopped', 'info');
542
+ });
543
+ }
544
+ }
545
+
546
+ // Progress callback
547
+ window.updateProgress = (current, total, message) => {
548
+ const progressBar = document.getElementById('progress-bar');
549
+ const progressText = document.getElementById('progress-text');
550
+
551
+ if (progressBar && progressText) {
552
+ const percentage = (current / total) * 100;
553
+ progressBar.style.width = percentage + '%';
554
+ progressText.textContent = message;
555
+ }
556
+ };
557
+
558
+ // ==================== Results Display ====================
559
+
560
+ function displayResults(result, datasetId) {
561
+ console.log('Displaying results:', {
562
+ Y_shape: [result.Y.length, result.Y[0]?.length],
563
+ P_shape: [result.P?.length, result.P?.[0]?.length],
564
+ Q_shape: [result.Q?.length, result.Q?.[0]?.length],
565
+ C_history_length: result.C_history?.length,
566
+ has_labels: result.has_labels
567
+ });
568
+
569
+ document.getElementById('results-section').style.display = 'block';
570
+
571
+ const Y = result.Y;
572
+
573
+ // 2D Scatter Plot
574
+ plotScatter(Y, datasetId, result.labels);
575
+
576
+ // Cost Plot
577
+ if (result.C_history && result.C_history.length > 0) {
578
+ plotCost(result.C_history);
579
+ } else {
580
+ console.warn('No cost history available');
581
+ }
582
+
583
+ // Matrix Heatmaps and Grids
584
+ if (result.P && result.P.length > 0) {
585
+ plotMatrix(result.P, 'p-matrix-plot', 'P Matrix (High-D Affinities)');
586
+ displayMatrixGrid(result.P, 'p-matrix-grid', 'P', 'y');
587
+ } else {
588
+ console.warn('P matrix not available');
589
+ }
590
+
591
+ if (result.Q && result.Q.length > 0) {
592
+ plotMatrix(result.Q, 'q-matrix-plot', 'Q Matrix (Low-D Affinities)');
593
+ displayMatrixGrid(result.Q, 'q-matrix-grid', 'Q', 'y');
594
+ } else {
595
+ console.warn('Q matrix not available');
596
+ }
597
+
598
+ // Distances between y_i in the embedding
599
+ const distances = computePairwiseDistances(Y);
600
+ result.D = distances;
601
+ plotMatrix(distances, 'd-matrix-plot', 'Distances Between y_i (Embedding)');
602
+ displayMatrixGrid(distances, 'd-matrix-grid', 'D', 'y');
603
+
604
+ // Coordinates Table
605
+ displayCoordinatesTable(Y);
606
+ }
607
+
608
+ function computePairwiseDistances(Y) {
609
+ const n = Y.length;
610
+ const distances = new Array(n);
611
+
612
+ for (let i = 0; i < n; i++) {
613
+ distances[i] = new Array(n);
614
+ for (let j = 0; j < n; j++) {
615
+ if (i === j) {
616
+ distances[i][j] = 0;
617
+ continue;
618
+ }
619
+ const dx = Y[i][0] - Y[j][0];
620
+ const dy = Y[i][1] - Y[j][1];
621
+ distances[i][j] = Math.sqrt(dx * dx + dy * dy);
622
+ }
623
+ }
624
+
625
+ return distances;
626
+ }
627
+
628
+ function plotScatter(Y, datasetId, labels) {
629
+ // Color palette for MNIST digits (0-9)
630
+ const digitColors = [
631
+ '#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6',
632
+ '#1abc9c', '#e67e22', '#95a5a6', '#34495e', '#c0392b'
633
+ ];
634
+
635
+ let trace;
636
+
637
+ if (labels && labels.length === Y.length) {
638
+ // Create separate trace for each digit class
639
+ const traces = [];
640
+ const uniqueLabels = [...new Set(labels)].sort((a, b) => a - b);
641
+
642
+ uniqueLabels.forEach(label => {
643
+ const indices = labels.map((l, i) => l === label ? i : -1).filter(i => i >= 0);
644
+ const color = digitColors[label % digitColors.length];
645
+
646
+ traces.push({
647
+ x: indices.map(i => Y[i][0]),
648
+ y: indices.map(i => Y[i][1]),
649
+ mode: 'markers',
650
+ type: 'scatter',
651
+ name: `Digit ${label}`,
652
+ marker: {
653
+ size: 8,
654
+ color: color,
655
+ line: {
656
+ color: '#ffffff',
657
+ width: 1
658
+ }
659
+ },
660
+ hovertext: indices.map(i => `Digit ${label}<br>Point ${i+1}<br>Dim 1: ${Y[i][0].toFixed(3)}<br>Dim 2: ${Y[i][1].toFixed(3)}`),
661
+ hoverinfo: 'text'
662
+ });
663
+ });
664
+
665
+ const layout = {
666
+ title: {
667
+ text: 't-SNE Embedding (Colored by True Labels)',
668
+ font: { size: 18, family: 'Segoe UI, sans-serif' }
669
+ },
670
+ xaxis: { title: 'Dimension 1', gridcolor: '#e0e0e0' },
671
+ yaxis: { title: 'Dimension 2', gridcolor: '#e0e0e0' },
672
+ hovermode: 'closest',
673
+ plot_bgcolor: '#fafafa',
674
+ paper_bgcolor: '#ffffff',
675
+ showlegend: true,
676
+ legend: {
677
+ orientation: 'h',
678
+ y: -0.2
679
+ }
680
+ };
681
+
682
+ Plotly.newPlot('tsne-plot', traces, layout);
683
+ } else {
684
+ // Default plot without labels
685
+ trace = {
686
+ x: Y.map(p => p[0]),
687
+ y: Y.map(p => p[1]),
688
+ mode: 'markers+text',
689
+ type: 'scatter',
690
+ marker: {
691
+ size: 10,
692
+ color: '#667eea',
693
+ line: {
694
+ color: '#ffffff',
695
+ width: 1
696
+ }
697
+ },
698
+ text: Y.map((p, i) => `y${i+1}`),
699
+ textposition: 'top center',
700
+ textfont: {
701
+ size: 10,
702
+ color: '#1f2937'
703
+ },
704
+ hovertext: Y.map((p, i) => `Point y${i+1}<br>Dim 1: ${p[0].toFixed(3)}<br>Dim 2: ${p[1].toFixed(3)}`),
705
+ hoverinfo: 'text'
706
+ };
707
+
708
+ const layout = {
709
+ title: {
710
+ text: 't-SNE Embedding',
711
+ font: { size: 18, family: 'Segoe UI, sans-serif' }
712
+ },
713
+ xaxis: { title: 'Dimension 1', gridcolor: '#e0e0e0' },
714
+ yaxis: { title: 'Dimension 2', gridcolor: '#e0e0e0' },
715
+ hovermode: 'closest',
716
+ plot_bgcolor: '#fafafa',
717
+ paper_bgcolor: '#ffffff'
718
+ };
719
+
720
+ Plotly.newPlot('tsne-plot', [trace], layout);
721
+ }
722
+
723
+ // Add click handler for images
724
+ if (datasetId && (datasetId.startsWith('images_') || datasetId.startsWith('mnist_'))) {
725
+ document.getElementById('tsne-plot').on('plotly_click', async (data) => {
726
+ const pointIndex = data.points[0].pointIndex;
727
+ await showImagePreview(datasetId, pointIndex);
728
+ });
729
+ }
730
+ }
731
+
732
+ function plotCost(costHistory) {
733
+ const trace = {
734
+ y: costHistory,
735
+ type: 'scatter',
736
+ mode: 'lines',
737
+ line: { color: '#e74c3c', width: 2 }
738
+ };
739
+
740
+ const layout = {
741
+ title: {
742
+ text: 'KL Divergence over Iterations',
743
+ font: { size: 18, family: 'Segoe UI, sans-serif' }
744
+ },
745
+ xaxis: { title: 'Iteration', gridcolor: '#e0e0e0' },
746
+ yaxis: { title: 'Cost (KL Divergence)', gridcolor: '#e0e0e0' },
747
+ plot_bgcolor: '#fafafa',
748
+ paper_bgcolor: '#ffffff'
749
+ };
750
+
751
+ Plotly.newPlot('cost-plot', [trace], layout);
752
+ }
753
+
754
+ function plotMatrix(matrix, elementId, title) {
755
+ const maxSize = 100;
756
+ const n = matrix.length;
757
+
758
+ let displayMatrix = matrix;
759
+ if (n > maxSize) {
760
+ const step = Math.ceil(n / maxSize);
761
+ displayMatrix = [];
762
+ for (let i = 0; i < n; i += step) {
763
+ const row = [];
764
+ for (let j = 0; j < n; j += step) {
765
+ row.push(matrix[i][j]);
766
+ }
767
+ displayMatrix.push(row);
768
+ }
769
+ }
770
+
771
+ const trace = {
772
+ z: displayMatrix,
773
+ type: 'heatmap',
774
+ colorscale: 'Viridis'
775
+ };
776
+
777
+ const layout = {
778
+ title: {
779
+ text: title + (n > maxSize ? ' (downsampled)' : ''),
780
+ font: { size: 16, family: 'Segoe UI, sans-serif' }
781
+ },
782
+ xaxis: { title: 'Point j' },
783
+ yaxis: { title: 'Point i' },
784
+ paper_bgcolor: '#ffffff'
785
+ };
786
+
787
+ Plotly.newPlot(elementId, [trace], layout);
788
+ }
789
+
790
+ function displayCoordinatesTable(Y) {
791
+ const coordsTable = document.getElementById('coords-table');
792
+ let html = '<div class="table-wrapper"><table class="data-table"><thead><tr><th>Point</th><th>Dim 1</th><th>Dim 2</th></tr></thead><tbody>';
793
+
794
+ const maxRows = Math.min(20, Y.length);
795
+ for (let i = 0; i < maxRows; i++) {
796
+ html += `<tr><td>y${i+1}</td><td>${Y[i][0].toFixed(4)}</td><td>${Y[i][1].toFixed(4)}</td></tr>`;
797
+ }
798
+
799
+ if (Y.length > 20) {
800
+ html += `<tr><td colspan="3" class="more-rows">... (${Y.length - 20} more rows)</td></tr>`;
801
+ }
802
+
803
+ html += '</tbody></table></div>';
804
+ coordsTable.innerHTML = html;
805
+ }
806
+
807
+ function displayMatrixGrid(matrix, elementId, matrixName, labelPrefix = '') {
808
+ const gridContainer = document.getElementById(elementId);
809
+ const n = matrix.length;
810
+ const maxDisplay = 20; // Show max 20x20 for performance
811
+
812
+ let html = '<div class="table-wrapper" style="max-height: 500px; overflow: auto;"><table class="data-table matrix-grid"><thead><tr><th></th>';
813
+
814
+ // Column headers
815
+ const displayN = Math.min(n, maxDisplay);
816
+ for (let j = 0; j < displayN; j++) {
817
+ const label = labelPrefix ? `${labelPrefix}${j + 1}` : `${j + 1}`;
818
+ html += `<th>${label}</th>`;
819
+ }
820
+ if (n > maxDisplay) {
821
+ html += '<th>...</th>';
822
+ }
823
+ html += '</tr></thead><tbody>';
824
+
825
+ // Matrix rows
826
+ for (let i = 0; i < displayN; i++) {
827
+ const label = labelPrefix ? `${labelPrefix}${i + 1}` : `${i + 1}`;
828
+ html += `<tr><td><strong>${label}</strong></td>`;
829
+ for (let j = 0; j < displayN; j++) {
830
+ const value = matrix[i][j];
831
+ const cellClass = i === j ? 'diagonal' : '';
832
+ html += `<td class="${cellClass}">${value.toFixed(6)}</td>`;
833
+ }
834
+ if (n > maxDisplay) {
835
+ html += '<td>...</td>';
836
+ }
837
+ html += '</tr>';
838
+ }
839
+
840
+ if (n > maxDisplay) {
841
+ html += `<tr><td><strong>...</strong></td>${'<td>...</td>'.repeat(displayN + 1)}</tr>`;
842
+ }
843
+
844
+ html += '</tbody></table></div>';
845
+ html += `<p class="info">Showing ${displayN}x${displayN} of ${n}x${n} matrix</p>`;
846
+
847
+ gridContainer.innerHTML = html;
848
+ }
849
+
850
+ function toggleMatrixView(matrixName, viewType) {
851
+ const plotId = `${matrixName.toLowerCase()}-matrix-plot`;
852
+ const gridId = `${matrixName.toLowerCase()}-matrix-grid`;
853
+
854
+ const plotDiv = document.getElementById(plotId);
855
+ const gridDiv = document.getElementById(gridId);
856
+
857
+ if (viewType === 'heatmap') {
858
+ plotDiv.style.display = 'block';
859
+ gridDiv.style.display = 'none';
860
+ } else if (viewType === 'grid') {
861
+ plotDiv.style.display = 'none';
862
+ gridDiv.style.display = 'block';
863
+ }
864
+ }
865
+
866
+ // ==================== Clustering ====================
867
+
868
+ // Auto-clustering removed - user can manually run clustering from the Clustering section
869
+ // async function runAutoClusteringForMNIST(datasetId) {
870
+ // ...
871
+ // }
872
+
873
+ function setupClustering() {
874
+ const methodSelect = document.getElementById('cluster-method');
875
+ const runBtn = document.getElementById('run-cluster-btn');
876
+
877
+ if (methodSelect) {
878
+ methodSelect.addEventListener('change', (e) => {
879
+ const method = e.target.value;
880
+ document.getElementById('kmeans-params').classList.toggle('hidden', method !== 'kmeans');
881
+ document.getElementById('dbscan-params').classList.toggle('hidden', method !== 'dbscan');
882
+ });
883
+ }
884
+
885
+ if (runBtn) {
886
+ runBtn.addEventListener('click', async () => {
887
+ const datasetId = document.getElementById('data-source').value;
888
+ const method = document.getElementById('cluster-method').value;
889
+
890
+ const params = {
891
+ k: parseInt(document.getElementById('kmeans-k').value) || 3,
892
+ eps: parseFloat(document.getElementById('dbscan-eps').value) || 0.5,
893
+ min_samples: parseInt(document.getElementById('dbscan-minsamples').value) || 5
894
+ };
895
+
896
+ const result = await safeAPICall(
897
+ async () => await pywebview.api.run_clustering(
898
+ datasetId, method, params.k, params.eps, params.min_samples
899
+ )
900
+ );
901
+
902
+ if (result && result.success) {
903
+ updateScatterWithClusters(result.labels, result.summary);
904
+ showNotification('Clustering completed!', 'success');
905
+ }
906
+ });
907
+ }
908
+ }
909
+
910
+ function updateScatterWithClusters(labels, summary) {
911
+ const Y = currentResults.Y;
912
+
913
+ const trace = {
914
+ x: Y.map(p => p[0]),
915
+ y: Y.map(p => p[1]),
916
+ mode: 'markers',
917
+ type: 'scatter',
918
+ marker: {
919
+ size: 10,
920
+ color: labels,
921
+ colorscale: 'Viridis',
922
+ showscale: true,
923
+ line: { color: '#ffffff', width: 1 }
924
+ },
925
+ text: Y.map((p, i) => `Point y${i + 1}<br>Cluster: ${labels[i]}<br>Dim 1: ${p[0].toFixed(3)}<br>Dim 2: ${p[1].toFixed(3)}`),
926
+ hoverinfo: 'text'
927
+ };
928
+
929
+ const layout = {
930
+ title: {
931
+ text: 't-SNE Embedding (Colored by Cluster)',
932
+ font: { size: 18, family: 'Segoe UI, sans-serif' }
933
+ },
934
+ xaxis: { title: 'Dimension 1', gridcolor: '#e0e0e0' },
935
+ yaxis: { title: 'Dimension 2', gridcolor: '#e0e0e0' },
936
+ hovermode: 'closest',
937
+ plot_bgcolor: '#fafafa',
938
+ paper_bgcolor: '#ffffff'
939
+ };
940
+
941
+ Plotly.newPlot('tsne-plot', [trace], layout);
942
+
943
+ // Display summary
944
+ displayClusterSummary(summary);
945
+ }
946
+
947
+ function displayClusterSummary(summary) {
948
+ const summaryDiv = document.getElementById('cluster-summary');
949
+ let html = '<h4>Cluster Summary</h4><div class="table-wrapper"><table class="data-table"><thead><tr><th>Cluster</th><th>Count</th></tr></thead><tbody>';
950
+
951
+ summary.forEach(item => {
952
+ html += `<tr><td>${item.label}</td><td>${item.count}</td></tr>`;
953
+ });
954
+
955
+ html += '</tbody></table></div>';
956
+ summaryDiv.innerHTML = html;
957
+ }
958
+
959
+ // ==================== Export ====================
960
+
961
+ function setupExport() {
962
+ const exportBtn = document.getElementById('export-btn');
963
+ if (exportBtn) {
964
+ exportBtn.addEventListener('click', async () => {
965
+ const datasetId = document.getElementById('data-source').value;
966
+ const result = await safeAPICall(
967
+ async () => await pywebview.api.export_results(datasetId)
968
+ );
969
+
970
+ if (result && result.success) {
971
+ downloadFile(result.csv, 'tsne_results.csv', 'text/csv');
972
+ showNotification('Results exported successfully!', 'success');
973
+ }
974
+ });
975
+ }
976
+ }
977
+
978
+ window.downloadMatrix = function(matrixType) {
979
+ if (!currentResults) {
980
+ showNotification('No results to export', 'warning');
981
+ return;
982
+ }
983
+
984
+ let matrix = null;
985
+ if (matrixType === 'P') matrix = currentResults.P;
986
+ if (matrixType === 'Q') matrix = currentResults.Q;
987
+ if (matrixType === 'D') matrix = currentResults.D;
988
+
989
+ if (!matrix) {
990
+ showNotification(`Matrix ${matrixType} not available`, 'warning');
991
+ return;
992
+ }
993
+
994
+ const csv = matrix.map(row => row.join(',')).join('\n');
995
+ const filename = matrixType === 'D' ? 'embedding_distances.csv' : `${matrixType}_matrix.csv`;
996
+ downloadFile(csv, filename, 'text/csv');
997
+ showNotification(`${matrixType} matrix exported!`, 'success');
998
+ };
999
+
1000
+ function downloadFile(content, filename, type) {
1001
+ const blob = new Blob([content], { type: type });
1002
+ const url = URL.createObjectURL(blob);
1003
+ const a = document.createElement('a');
1004
+ a.href = url;
1005
+ a.download = filename;
1006
+ a.click();
1007
+ URL.revokeObjectURL(url);
1008
+ }
1009
+
1010
+ // ==================== Upload ====================
1011
+
1012
+ function setupUpload() {
1013
+ setupCSVUpload();
1014
+ setupImageUpload();
1015
+ }
1016
+
1017
+ function setupCSVUpload() {
1018
+ const uploadBtn = document.getElementById('csv-upload-btn');
1019
+ if (uploadBtn) {
1020
+ uploadBtn.addEventListener('click', async () => {
1021
+ const fileInput = document.getElementById('csv-upload');
1022
+ const files = fileInput.files;
1023
+
1024
+ if (files.length === 0) {
1025
+ showNotification('Please select CSV file(s)', 'warning');
1026
+ return;
1027
+ }
1028
+
1029
+ for (let file of files) {
1030
+ const reader = new FileReader();
1031
+ reader.onload = async (e) => {
1032
+ const content = e.target.result;
1033
+ const result = await safeAPICall(
1034
+ async () => await pywebview.api.upload_csv(file.name, content, ',')
1035
+ );
1036
+
1037
+ if (result && result.success) {
1038
+ showNotification(`Uploaded ${file.name}`, 'success');
1039
+ await updateDataSourceDropdown();
1040
+ await refreshDatasetList();
1041
+
1042
+ if (result.numeric_columns.length > 0) {
1043
+ displayCSVColumns(result.numeric_columns);
1044
+ }
1045
+ }
1046
+ };
1047
+ reader.readAsText(file);
1048
+ }
1049
+ });
1050
+ }
1051
+ }
1052
+
1053
+ function displayCSVColumns(columns) {
1054
+ const columnsList = document.getElementById('csv-columns-list');
1055
+ if (!columnsList) return;
1056
+
1057
+ columnsList.innerHTML = '';
1058
+ columns.forEach(col => {
1059
+ const label = document.createElement('label');
1060
+ label.className = 'checkbox-label';
1061
+ label.innerHTML = `<input type="checkbox" value="${col}" checked> ${col}`;
1062
+ columnsList.appendChild(label);
1063
+ });
1064
+ }
1065
+
1066
+ function setupImageUpload() {
1067
+ const imageUploadBtn = document.getElementById('image-upload-btn');
1068
+ const folderUploadBtn = document.getElementById('folder-upload-btn');
1069
+ const imageInput = document.getElementById('image-upload');
1070
+ const folderInput = document.getElementById('folder-upload');
1071
+
1072
+ if (imageUploadBtn) {
1073
+ imageUploadBtn.addEventListener('click', () => imageInput.click());
1074
+ }
1075
+
1076
+ if (folderUploadBtn) {
1077
+ folderUploadBtn.addEventListener('click', () => folderInput.click());
1078
+ }
1079
+
1080
+ if (imageInput) {
1081
+ imageInput.addEventListener('change', (e) => handleImageUpload(e.target.files));
1082
+ }
1083
+
1084
+ if (folderInput) {
1085
+ folderInput.addEventListener('change', (e) => handleImageUpload(e.target.files));
1086
+ }
1087
+ }
1088
+
1089
+ async function handleImageUpload(files) {
1090
+ if (files.length === 0) return;
1091
+
1092
+ showNotification('Uploading images...', 'info');
1093
+ const imageFiles = [];
1094
+
1095
+ for (let file of files) {
1096
+ if (!file.type.startsWith('image/')) continue;
1097
+
1098
+ const content = await readFileAsDataURL(file);
1099
+ imageFiles.push({ name: file.name, content: content });
1100
+ }
1101
+
1102
+ if (imageFiles.length === 0) {
1103
+ showNotification('No valid image files found', 'warning');
1104
+ return;
1105
+ }
1106
+
1107
+ const result = await safeAPICall(
1108
+ async () => await pywebview.api.upload_images(imageFiles)
1109
+ );
1110
+
1111
+ if (result && result.success) {
1112
+ showNotification(`Uploaded ${result.count} images`, 'success');
1113
+ await updateDataSourceDropdown();
1114
+ await refreshDatasetList();
1115
+ }
1116
+ }
1117
+
1118
+ function readFileAsDataURL(file) {
1119
+ return new Promise((resolve) => {
1120
+ const reader = new FileReader();
1121
+ reader.onload = (e) => resolve(e.target.result);
1122
+ reader.readAsDataURL(file);
1123
+ });
1124
+ }
1125
+
1126
+ async function refreshDatasetList() {
1127
+ const datasets = await safeAPICall(async () => await pywebview.api.list_datasets());
1128
+ if (!datasets) return;
1129
+
1130
+ const container = document.getElementById('datasets-container');
1131
+ if (!container) return;
1132
+
1133
+ if (datasets.length === 0) {
1134
+ container.innerHTML = '<p class="empty-state">📭 No datasets uploaded yet</p>';
1135
+ return;
1136
+ }
1137
+
1138
+ let html = '<ul class="dataset-list">';
1139
+ datasets.forEach(dataset => {
1140
+ let icon = '📊';
1141
+ let label = '';
1142
+
1143
+ if (dataset.type === 'csv') {
1144
+ icon = '📊';
1145
+ label = `CSV: ${dataset.name} (${dataset.shape[0]}×${dataset.shape[1]})`;
1146
+ } else if (dataset.type === 'images') {
1147
+ icon = '🖼️';
1148
+ label = `Images: ${dataset.count} files`;
1149
+ } else if (dataset.type === 'synthetic') {
1150
+ icon = '🔢';
1151
+ label = `Synthetic: ${dataset.shape[0]}×${dataset.shape[1]}`;
1152
+ } else if (dataset.type === 'mnist') {
1153
+ icon = '✏️';
1154
+ label = `${dataset.name}`;
1155
+ }
1156
+
1157
+ html += `<li class="dataset-item"><span class="dataset-icon">${icon}</span><span class="dataset-label">${label}</span></li>`;
1158
+ });
1159
+ html += '</ul>';
1160
+
1161
+ container.innerHTML = html;
1162
+ }
1163
+
1164
+ // ==================== Modal ====================
1165
+
1166
+ function setupModal() {
1167
+ const closeBtn = document.querySelector('.modal-close');
1168
+ const modal = document.getElementById('image-modal');
1169
+
1170
+ if (closeBtn) {
1171
+ closeBtn.addEventListener('click', () => {
1172
+ modal.classList.add('hidden');
1173
+ });
1174
+ }
1175
+
1176
+ window.addEventListener('click', (e) => {
1177
+ if (e.target === modal) {
1178
+ modal.classList.add('hidden');
1179
+ }
1180
+ });
1181
+ }
1182
+
1183
+ async function showImagePreview(datasetId, index) {
1184
+ const result = await safeAPICall(
1185
+ async () => await pywebview.api.get_image_at_index(datasetId, index)
1186
+ );
1187
+
1188
+ if (result && result.success) {
1189
+ const modal = document.getElementById('image-modal');
1190
+ const modalTitle = document.getElementById('modal-title');
1191
+ const modalImage = document.getElementById('modal-image');
1192
+
1193
+ modalTitle.textContent = result.name;
1194
+ modalImage.src = result.image;
1195
+ modal.classList.remove('hidden');
1196
+ }
1197
+ }
1198
+
1199
+ // ==================== Initialize ====================
1200
+
1201
+ // Wait for pywebview API to be available
1202
+ function waitForPyWebView() {
1203
+ return new Promise((resolve) => {
1204
+ if (typeof pywebview !== 'undefined' && pywebview.api) {
1205
+ console.log('PyWebView API already available');
1206
+ resolve();
1207
+ } else {
1208
+ console.log('Waiting for PyWebView API...');
1209
+ window.addEventListener('pywebviewready', () => {
1210
+ console.log('PyWebView API ready!');
1211
+ resolve();
1212
+ });
1213
+
1214
+ // Fallback: poll for API availability
1215
+ const checkInterval = setInterval(() => {
1216
+ if (typeof pywebview !== 'undefined' && pywebview.api) {
1217
+ console.log('PyWebView API detected via polling');
1218
+ clearInterval(checkInterval);
1219
+ resolve();
1220
+ }
1221
+ }, 100);
1222
+
1223
+ // Timeout after 10 seconds
1224
+ setTimeout(() => {
1225
+ clearInterval(checkInterval);
1226
+ if (typeof pywebview === 'undefined' || !pywebview.api) {
1227
+ console.error('PyWebView API failed to load within 10 seconds');
1228
+ showNotification('Failed to connect to backend. Please restart the application.', 'error');
1229
+ }
1230
+ }, 10000);
1231
+ }
1232
+ });
1233
+ }
1234
+
1235
+ // Initialize when both DOM and pywebview are ready
1236
+ async function startApp() {
1237
+ console.log('Starting app initialization...');
1238
+
1239
+ // Wait for pywebview API
1240
+ await waitForPyWebView();
1241
+
1242
+ // Initialize the app
1243
+ init();
1244
+
1245
+ console.log('App initialization complete!');
1246
+ }
1247
+
1248
+ // Start when DOM is ready
1249
+ if (document.readyState === 'loading') {
1250
+ document.addEventListener('DOMContentLoaded', startApp);
1251
+ } else {
1252
+ startApp();
1253
+ }
web/index.html ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>t-SNE Explorer</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
9
+ </head>
10
+ <body>
11
+ <div class="container">
12
+ <header>
13
+ <h1>t-SNE Explorer</h1>
14
+ <p class="subtitle">Transparent t-SNE with synthetic data generation and file uploads</p>
15
+ </header>
16
+
17
+ <div class="tabs">
18
+ <button class="tab-button active" data-tab="tsne-tab">t-SNE</button>
19
+ <button class="tab-button" data-tab="upload-tab">Upload</button>
20
+ </div>
21
+
22
+ <!-- Tab 1: t-SNE -->
23
+ <div id="tsne-tab" class="tab-content active">
24
+ <!-- Section A: Synthetic Data Generator -->
25
+ <section class="card">
26
+ <h2>A) Synthetic Data Generator</h2>
27
+ <p class="info">Generate n points in d dimensions with k distinct distance types. Optimal: k=1 (n≤d+1 simplex), k=2 (n=5 pentagon), k=3 (n=7 heptagon). Larger n uses approximate lattice constructions.</p>
28
+
29
+ <div class="controls">
30
+ <div class="control-group">
31
+ <label>n (number of points):</label>
32
+ <input type="number" id="synth-n" value="6" min="1" max="100">
33
+ </div>
34
+ <div class="control-group">
35
+ <label>d (dimensions):</label>
36
+ <input type="number" id="synth-d" value="10" min="1" max="100">
37
+ </div>
38
+ <div class="control-group">
39
+ <label>k (distinct distance types):</label>
40
+ <input type="number" id="synth-k" value="2" min="1" step="1">
41
+ </div>
42
+ <div class="control-group">
43
+ <label>seed:</label>
44
+ <input type="number" id="synth-seed" value="42" min="0">
45
+ </div>
46
+ </div>
47
+
48
+ <button id="generate-btn" class="btn btn-primary">Generate Points</button>
49
+
50
+ <div id="synth-output" class="output hidden">
51
+ <h3>Generated Points</h3>
52
+ <div id="synth-stats"></div>
53
+ <div id="synth-table-container"></div>
54
+ <h3>Pairwise Distances</h3>
55
+ <div id="synth-distances-container"></div>
56
+ </div>
57
+ </section>
58
+
59
+ <!-- Section B: t-SNE Runner -->
60
+ <section class="card">
61
+ <h2>B) t-SNE Runner</h2>
62
+
63
+ <div class="controls">
64
+ <div class="control-group">
65
+ <label>Data Source:</label>
66
+ <select id="data-source">
67
+ <option value="">-- Select Dataset --</option>
68
+ <option value="synthetic">Use Synthetic Points</option>
69
+ <option value="load-mnist">Load MNIST Dataset</option>
70
+ </select>
71
+ <small style="color: #10b981; display: block; margin-top: 5px; font-weight: bold;">✓ v6: tqdm-style progress bars for loading & t-SNE!</small>
72
+ </div>
73
+
74
+ <div class="control-group" id="mnist-load-group" style="display: none; background: #f8f9fa; padding: 15px; border-radius: 8px; margin-top: 10px; border: 2px solid #667eea;">
75
+ <label style="font-weight: bold; color: #667eea; margin-bottom: 10px; display: block;">📊 MNIST Configuration</label>
76
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
77
+ <div>
78
+ <label style="display: block; margin-bottom: 5px; font-size: 0.9em;">Subset:</label>
79
+ <select id="mnist-subset" style="width: 100%; padding: 6px;">
80
+ <option value="train">Training Set</option>
81
+ <option value="test">Test Set</option>
82
+ </select>
83
+ </div>
84
+ <div>
85
+ <label style="display: block; margin-bottom: 5px; font-size: 0.9em;">Number of Samples:</label>
86
+ <input type="number" id="mnist-samples" value="1000" min="100" max="10000" step="100" style="width: 100%; padding: 6px;">
87
+ </div>
88
+ </div>
89
+ <button id="load-mnist-btn" class="btn btn-primary" style="width: 100%; padding: 10px; font-weight: bold;">Load MNIST Dataset</button>
90
+
91
+ <!-- Progress Bar -->
92
+ <div id="mnist-progress-container" style="display: none; margin-top: 15px;">
93
+ <div style="background: #e5e7eb; border-radius: 8px; height: 24px; overflow: hidden; position: relative;">
94
+ <div id="mnist-progress-bar" style="background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); height: 100%; width: 0%; transition: width 0.3s ease; display: flex; align-items: center; justify-content: center;">
95
+ <span id="mnist-progress-text" style="color: white; font-size: 0.85em; font-weight: bold; position: absolute; left: 50%; transform: translateX(-50%);"></span>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <div id="mnist-status" style="margin-top: 10px; font-size: 0.9em; color: #666; padding: 8px; background: white; border-radius: 4px; display: none;"></div>
101
+ </div>
102
+
103
+ <div class="control-group" id="csv-columns-group" style="display: none;">
104
+ <label>Select Numeric Columns:</label>
105
+ <div id="csv-columns-list"></div>
106
+ <div style="margin-top: 5px;">
107
+ <label>Handle Missing Values:</label>
108
+ <select id="csv-missing">
109
+ <option value="drop">Drop rows with NA</option>
110
+ <option value="mean">Fill with mean</option>
111
+ <option value="zero">Fill with zero</option>
112
+ </select>
113
+ </div>
114
+ <button id="prepare-csv-btn" class="btn btn-secondary">Prepare Dataset</button>
115
+ </div>
116
+
117
+ <div class="control-group" id="image-embed-group" style="display: none;">
118
+ <label>Embedding Method:</label>
119
+ <select id="embed-method">
120
+ <option value="hist">Color Histogram (fast)</option>
121
+ <option value="clip">CLIP (requires torch/clip)</option>
122
+ </select>
123
+ <button id="compute-embed-btn" class="btn btn-secondary">Compute Embeddings</button>
124
+ <div id="embed-status"></div>
125
+ </div>
126
+
127
+ <div class="control-group">
128
+ <label>Initialization:</label>
129
+ <select id="init-method">
130
+ <option value="random">Random</option>
131
+ <option value="custom">Custom (User-defined)</option>
132
+ </select>
133
+ </div>
134
+
135
+ <div class="control-group" id="custom-init-group" style="display: none;">
136
+ <label>Custom Initial Coordinates (JSON format):</label>
137
+ <textarea id="custom-init-coords" rows="4" placeholder='[[x1, y1], [x2, y2], ...]'></textarea>
138
+ <p class="info" style="font-size: 0.85em;">Provide n×2 array of initial 2D coordinates</p>
139
+ </div>
140
+
141
+ <div class="control-group">
142
+ <label>Perplexity:</label>
143
+ <input type="number" id="perplexity" value="30" min="5" max="50">
144
+ </div>
145
+
146
+ <div class="control-group">
147
+ <label>Learning Rate:</label>
148
+ <input type="number" id="learning-rate" value="200" min="10" max="1000">
149
+ </div>
150
+
151
+ <div class="control-group">
152
+ <label>Iterations:</label>
153
+ <input type="number" id="iterations" value="1000" min="100" max="5000">
154
+ </div>
155
+
156
+ <div class="control-group">
157
+ <label>Early Exaggeration:</label>
158
+ <input type="number" id="early-exag" value="12" min="1" max="50">
159
+ </div>
160
+
161
+ <div class="control-group">
162
+ <label>Momentum:</label>
163
+ <input type="number" id="momentum" value="0.8" min="0" max="1" step="0.1">
164
+ </div>
165
+
166
+ <div class="control-group">
167
+ <label>Seed:</label>
168
+ <input type="number" id="tsne-seed" value="42" min="0">
169
+ </div>
170
+ </div>
171
+
172
+ <div class="button-group">
173
+ <button id="run-tsne-btn" class="btn btn-primary">Run t-SNE</button>
174
+ <button id="stop-tsne-btn" class="btn btn-danger" style="display: none;">Stop</button>
175
+ </div>
176
+
177
+ <div id="progress-container" class="progress-container hidden">
178
+ <div style="background: #e5e7eb; border-radius: 8px; height: 24px; overflow: hidden; position: relative;">
179
+ <div id="progress-bar" style="background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); height: 100%; width: 0%; transition: width 0.3s ease; display: flex; align-items: center; justify-content: center;">
180
+ <span id="progress-text" style="color: white; font-size: 0.85em; font-weight: bold; position: absolute; left: 50%; transform: translateX(-50%);">Initializing...</span>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </section>
185
+
186
+ <!-- Section C: Outputs & Internals -->
187
+ <section class="card" id="results-section" style="display: none;">
188
+ <h2>C) Outputs & Internals</h2>
189
+
190
+ <div class="results-grid">
191
+ <!-- 2D Scatter Plot -->
192
+ <div class="result-box">
193
+ <h3>2D t-SNE Result</h3>
194
+ <div id="tsne-plot"></div>
195
+ </div>
196
+
197
+ <!-- Cluster Results (for MNIST/Images) - REMOVED -->
198
+ <div class="result-box" id="cluster-results-box" style="display: none !important;">
199
+ <h3>Cluster Distribution</h3>
200
+ <div id="cluster-summary" style="min-height: 200px;">
201
+ <p style="color: #999; text-align: center; padding: 40px;">Run t-SNE to see cluster results</p>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- Cost over iterations -->
206
+ <div class="result-box">
207
+ <h3>Cost (KL Divergence) Over Iterations</h3>
208
+ <div id="cost-plot"></div>
209
+ </div>
210
+
211
+ <!-- P matrix heatmap -->
212
+ <div class="result-box">
213
+ <h3>P Matrix (High-D Affinities)</h3>
214
+ <div class="button-group">
215
+ <button class="btn btn-secondary" onclick="toggleMatrixView('P', 'heatmap')">Heatmap</button>
216
+ <button class="btn btn-secondary" onclick="toggleMatrixView('P', 'grid')">Grid View</button>
217
+ <button class="btn btn-secondary" onclick="downloadMatrix('P')">Download CSV</button>
218
+ </div>
219
+ <div id="p-matrix-plot"></div>
220
+ <div id="p-matrix-grid" style="display: none;"></div>
221
+ </div>
222
+
223
+ <!-- Q matrix heatmap -->
224
+ <div class="result-box">
225
+ <h3>Q Matrix (Low-D Affinities)</h3>
226
+ <div class="button-group">
227
+ <button class="btn btn-secondary" onclick="toggleMatrixView('Q', 'heatmap')">Heatmap</button>
228
+ <button class="btn btn-secondary" onclick="toggleMatrixView('Q', 'grid')">Grid View</button>
229
+ <button class="btn btn-secondary" onclick="downloadMatrix('Q')">Download CSV</button>
230
+ </div>
231
+ <div id="q-matrix-plot"></div>
232
+ <div id="q-matrix-grid" style="display: none;"></div>
233
+ </div>
234
+
235
+ <!-- Y distances heatmap -->
236
+ <div class="result-box">
237
+ <h3>Distances Between y<sub>i</sub> (Embedding)</h3>
238
+ <div class="button-group">
239
+ <button class="btn btn-secondary" onclick="toggleMatrixView('D', 'heatmap')">Heatmap</button>
240
+ <button class="btn btn-secondary" onclick="toggleMatrixView('D', 'grid')">Grid View</button>
241
+ <button class="btn btn-secondary" onclick="downloadMatrix('D')">Download CSV</button>
242
+ </div>
243
+ <div id="d-matrix-plot"></div>
244
+ <div id="d-matrix-grid" style="display: none;"></div>
245
+ </div>
246
+
247
+ <!-- Coordinates table -->
248
+ <div class="result-box">
249
+ <h3>2D Coordinates</h3>
250
+ <div id="coords-table"></div>
251
+ </div>
252
+
253
+ <!-- Clustering -->
254
+ <div class="result-box" id="clustering-section" style="display: none;">
255
+ <h3>Clustering</h3>
256
+ <div class="controls">
257
+ <div class="control-group">
258
+ <label>Method:</label>
259
+ <select id="cluster-method">
260
+ <option value="kmeans">K-Means</option>
261
+ <option value="dbscan">DBSCAN</option>
262
+ </select>
263
+ </div>
264
+ <div class="control-group" id="kmeans-params">
265
+ <label>k (clusters):</label>
266
+ <input type="number" id="kmeans-k" value="3" min="2" max="10">
267
+ </div>
268
+ <div class="control-group hidden" id="dbscan-params">
269
+ <label>eps:</label>
270
+ <input type="number" id="dbscan-eps" value="0.5" min="0.1" step="0.1">
271
+ <label>min_samples:</label>
272
+ <input type="number" id="dbscan-minsamples" value="5" min="1">
273
+ </div>
274
+ </div>
275
+ <button id="run-cluster-btn" class="btn btn-primary">Run Clustering</button>
276
+ <div id="cluster-summary"></div>
277
+ </div>
278
+ </div>
279
+
280
+ <div class="button-group">
281
+ <button id="export-btn" class="btn btn-primary">Export Results (CSV)</button>
282
+ </div>
283
+ </section>
284
+ </div>
285
+
286
+ <!-- Tab 2: Upload -->
287
+ <div id="upload-tab" class="tab-content">
288
+ <section class="card">
289
+ <h2>Upload Data</h2>
290
+
291
+ <div class="upload-section">
292
+ <h3>CSV Files</h3>
293
+ <input type="file" id="csv-upload" accept=".csv" multiple>
294
+ <button id="csv-upload-btn" class="btn btn-primary">Upload CSV</button>
295
+ </div>
296
+
297
+ <div class="upload-section">
298
+ <h3>Images</h3>
299
+ <p class="info">Upload individual images or an entire folder</p>
300
+ <input type="file" id="image-upload" accept="image/*" multiple>
301
+ <input type="file" id="folder-upload" webkitdirectory directory multiple style="display: none;">
302
+ <div class="button-group">
303
+ <button id="image-upload-btn" class="btn btn-primary">Upload Images</button>
304
+ <button id="folder-upload-btn" class="btn btn-primary">Upload Folder</button>
305
+ </div>
306
+ </div>
307
+
308
+ <div id="upload-list" class="upload-list">
309
+ <h3>Uploaded Datasets</h3>
310
+ <div id="datasets-container"></div>
311
+ </div>
312
+ </section>
313
+ </div>
314
+
315
+ <!-- Image Preview Modal -->
316
+ <div id="image-modal" class="modal hidden">
317
+ <div class="modal-content">
318
+ <span class="modal-close">&times;</span>
319
+ <h3 id="modal-title"></h3>
320
+ <img id="modal-image" src="" alt="Preview">
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ <script src="app.js"></script>
326
+ </body>
327
+ </html>
web/style.css ADDED
@@ -0,0 +1,1012 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ==================== CSS Variables ==================== */
2
+ :root {
3
+ --primary: #667eea;
4
+ --primary-dark: #5568d3;
5
+ --secondary: #764ba2;
6
+ --accent: #f093fb;
7
+ --success: #10b981;
8
+ --warning: #f59e0b;
9
+ --error: #ef4444;
10
+ --info: #3b82f6;
11
+
12
+ --bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
13
+ --bg-light: #f8f9fa;
14
+ --bg-white: #ffffff;
15
+ --bg-dark: #1a1a2e;
16
+
17
+ --text-primary: #1f2937;
18
+ --text-secondary: #6b7280;
19
+ --text-light: #9ca3af;
20
+
21
+ --border-color: #e5e7eb;
22
+ --border-radius: 12px;
23
+ --border-radius-lg: 16px;
24
+ --border-radius-sm: 8px;
25
+
26
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
27
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
28
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
29
+ --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
30
+
31
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
32
+ }
33
+
34
+ /* ==================== Reset & Base Styles ==================== */
35
+ * {
36
+ margin: 0;
37
+ padding: 0;
38
+ box-sizing: border-box;
39
+ }
40
+
41
+ body {
42
+ font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
43
+ background: var(--bg-gradient);
44
+ color: var(--text-primary);
45
+ min-height: 100vh;
46
+ padding: 20px;
47
+ line-height: 1.6;
48
+ -webkit-font-smoothing: antialiased;
49
+ -moz-osx-font-smoothing: grayscale;
50
+ }
51
+
52
+ /* ==================== Container ==================== */
53
+ .container {
54
+ max-width: 1600px;
55
+ margin: 0 auto;
56
+ background: var(--bg-white);
57
+ border-radius: var(--border-radius-lg);
58
+ box-shadow: var(--shadow-xl);
59
+ overflow: hidden;
60
+ animation: fadeIn 0.5s ease-out;
61
+ }
62
+
63
+ @keyframes fadeIn {
64
+ from {
65
+ opacity: 0;
66
+ transform: translateY(20px);
67
+ }
68
+ to {
69
+ opacity: 1;
70
+ transform: translateY(0);
71
+ }
72
+ }
73
+
74
+ /* ==================== Header ==================== */
75
+ header {
76
+ background: var(--bg-gradient);
77
+ color: white;
78
+ padding: 40px 30px;
79
+ text-align: center;
80
+ position: relative;
81
+ overflow: hidden;
82
+ }
83
+
84
+ header::before {
85
+ content: '';
86
+ position: absolute;
87
+ top: 0;
88
+ left: 0;
89
+ right: 0;
90
+ bottom: 0;
91
+ background: url('data:image/svg+xml,<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><defs><pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="10" cy="10" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
92
+ opacity: 0.3;
93
+ }
94
+
95
+ header h1 {
96
+ font-size: 3em;
97
+ margin-bottom: 10px;
98
+ font-weight: 800;
99
+ position: relative;
100
+ letter-spacing: -1px;
101
+ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
102
+ }
103
+
104
+ .subtitle {
105
+ font-size: 1.2em;
106
+ opacity: 0.95;
107
+ position: relative;
108
+ font-weight: 400;
109
+ }
110
+
111
+ /* ==================== Tabs ==================== */
112
+ .tabs {
113
+ display: flex;
114
+ background: var(--bg-light);
115
+ border-bottom: 2px solid var(--border-color);
116
+ position: sticky;
117
+ top: 0;
118
+ z-index: 100;
119
+ backdrop-filter: blur(10px);
120
+ }
121
+
122
+ .tab-button {
123
+ flex: 1;
124
+ padding: 18px 30px;
125
+ background: transparent;
126
+ border: none;
127
+ cursor: pointer;
128
+ font-size: 1.1em;
129
+ font-weight: 600;
130
+ color: var(--text-secondary);
131
+ transition: var(--transition);
132
+ position: relative;
133
+ border-bottom: 3px solid transparent;
134
+ }
135
+
136
+ .tab-button::before {
137
+ content: '';
138
+ position: absolute;
139
+ bottom: 0;
140
+ left: 50%;
141
+ width: 0;
142
+ height: 3px;
143
+ background: var(--bg-gradient);
144
+ transition: var(--transition);
145
+ transform: translateX(-50%);
146
+ }
147
+
148
+ .tab-button:hover {
149
+ background: rgba(102, 126, 234, 0.05);
150
+ color: var(--primary);
151
+ }
152
+
153
+ .tab-button.active {
154
+ color: var(--primary);
155
+ background: var(--bg-white);
156
+ }
157
+
158
+ .tab-button.active::before {
159
+ width: 100%;
160
+ }
161
+
162
+ /* ==================== Tab Content ==================== */
163
+ .tab-content {
164
+ display: none;
165
+ padding: 30px;
166
+ animation: slideIn 0.3s ease-out;
167
+ }
168
+
169
+ .tab-content.active {
170
+ display: block;
171
+ }
172
+
173
+ @keyframes slideIn {
174
+ from {
175
+ opacity: 0;
176
+ transform: translateX(-10px);
177
+ }
178
+ to {
179
+ opacity: 1;
180
+ transform: translateX(0);
181
+ }
182
+ }
183
+
184
+ /* ==================== Cards ==================== */
185
+ .card {
186
+ background: var(--bg-white);
187
+ border-radius: var(--border-radius);
188
+ padding: 30px;
189
+ margin-bottom: 25px;
190
+ box-shadow: var(--shadow-md);
191
+ border: 1px solid var(--border-color);
192
+ transition: var(--transition);
193
+ }
194
+
195
+ .card:hover {
196
+ box-shadow: var(--shadow-lg);
197
+ transform: translateY(-2px);
198
+ }
199
+
200
+ .card h2 {
201
+ color: var(--primary);
202
+ margin-bottom: 20px;
203
+ font-size: 1.8em;
204
+ font-weight: 700;
205
+ display: flex;
206
+ align-items: center;
207
+ gap: 12px;
208
+ }
209
+
210
+ .card h2::before {
211
+ content: '';
212
+ width: 4px;
213
+ height: 28px;
214
+ background: var(--bg-gradient);
215
+ border-radius: 2px;
216
+ }
217
+
218
+ .card h3 {
219
+ color: var(--text-primary);
220
+ margin-bottom: 15px;
221
+ font-size: 1.3em;
222
+ font-weight: 600;
223
+ }
224
+
225
+ /* ==================== Info Blocks ==================== */
226
+ .info {
227
+ background: linear-gradient(135deg, #e0f2fe 0%, #dbeafe 100%);
228
+ border-left: 4px solid var(--info);
229
+ padding: 15px 20px;
230
+ margin: 15px 0;
231
+ border-radius: var(--border-radius-sm);
232
+ font-size: 0.95em;
233
+ color: #0c4a6e;
234
+ display: flex;
235
+ align-items: center;
236
+ gap: 12px;
237
+ }
238
+
239
+ .info::before {
240
+ content: 'ℹ️';
241
+ font-size: 1.2em;
242
+ }
243
+
244
+ /* ==================== Controls ==================== */
245
+ .controls {
246
+ display: grid;
247
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
248
+ gap: 20px;
249
+ margin-bottom: 25px;
250
+ }
251
+
252
+ .control-group {
253
+ display: flex;
254
+ flex-direction: column;
255
+ gap: 8px;
256
+ }
257
+
258
+ .control-group label {
259
+ font-weight: 600;
260
+ color: var(--text-primary);
261
+ font-size: 0.95em;
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 6px;
265
+ }
266
+
267
+ .control-group input,
268
+ .control-group select {
269
+ padding: 12px 16px;
270
+ border: 2px solid var(--border-color);
271
+ border-radius: var(--border-radius-sm);
272
+ font-size: 1em;
273
+ font-family: inherit;
274
+ transition: var(--transition);
275
+ background: var(--bg-white);
276
+ color: var(--text-primary);
277
+ }
278
+
279
+ .control-group input:focus,
280
+ .control-group select:focus {
281
+ outline: none;
282
+ border-color: var(--primary);
283
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
284
+ }
285
+
286
+ .control-group input:hover,
287
+ .control-group select:hover {
288
+ border-color: var(--primary-dark);
289
+ }
290
+
291
+ /* ==================== Buttons ==================== */
292
+ .btn {
293
+ padding: 14px 28px;
294
+ border: none;
295
+ border-radius: var(--border-radius-sm);
296
+ font-size: 1em;
297
+ font-weight: 600;
298
+ cursor: pointer;
299
+ transition: var(--transition);
300
+ font-family: inherit;
301
+ display: inline-flex;
302
+ align-items: center;
303
+ justify-content: center;
304
+ gap: 8px;
305
+ position: relative;
306
+ overflow: hidden;
307
+ }
308
+
309
+ .btn::before {
310
+ content: '';
311
+ position: absolute;
312
+ top: 50%;
313
+ left: 50%;
314
+ width: 0;
315
+ height: 0;
316
+ border-radius: 50%;
317
+ background: rgba(255, 255, 255, 0.3);
318
+ transform: translate(-50%, -50%);
319
+ transition: width 0.6s, height 0.6s;
320
+ }
321
+
322
+ .btn:active::before {
323
+ width: 300px;
324
+ height: 300px;
325
+ }
326
+
327
+ .btn-primary {
328
+ background: var(--bg-gradient);
329
+ color: white;
330
+ box-shadow: var(--shadow-sm);
331
+ }
332
+
333
+ .btn-primary:hover {
334
+ transform: translateY(-2px);
335
+ box-shadow: var(--shadow-md);
336
+ }
337
+
338
+ .btn-primary:active {
339
+ transform: translateY(0);
340
+ }
341
+
342
+ .btn-secondary {
343
+ background: var(--text-secondary);
344
+ color: white;
345
+ }
346
+
347
+ .btn-secondary:hover {
348
+ background: var(--text-primary);
349
+ transform: translateY(-2px);
350
+ box-shadow: var(--shadow-sm);
351
+ }
352
+
353
+ .btn-danger {
354
+ background: var(--error);
355
+ color: white;
356
+ }
357
+
358
+ .btn-danger:hover {
359
+ background: #dc2626;
360
+ transform: translateY(-2px);
361
+ box-shadow: var(--shadow-sm);
362
+ }
363
+
364
+ .button-group {
365
+ display: flex;
366
+ flex-wrap: wrap;
367
+ gap: 12px;
368
+ margin-top: 20px;
369
+ }
370
+
371
+ /* ==================== Progress Bar ==================== */
372
+ .progress-container {
373
+ margin: 25px 0;
374
+ background: var(--bg-light);
375
+ border-radius: var(--border-radius);
376
+ overflow: hidden;
377
+ padding: 20px;
378
+ box-shadow: var(--shadow-sm);
379
+ border: 1px solid var(--border-color);
380
+ }
381
+
382
+ .progress-bar {
383
+ height: 32px;
384
+ background: var(--bg-gradient);
385
+ border-radius: var(--border-radius-sm);
386
+ transition: width 0.3s ease;
387
+ width: 0%;
388
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
389
+ position: relative;
390
+ overflow: hidden;
391
+ }
392
+
393
+ .progress-bar::after {
394
+ content: '';
395
+ position: absolute;
396
+ top: 0;
397
+ left: 0;
398
+ right: 0;
399
+ bottom: 0;
400
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
401
+ animation: shimmer 2s infinite;
402
+ }
403
+
404
+ @keyframes shimmer {
405
+ 0% {
406
+ transform: translateX(-100%);
407
+ }
408
+ 100% {
409
+ transform: translateX(100%);
410
+ }
411
+ }
412
+
413
+ #progress-text {
414
+ text-align: center;
415
+ margin-top: 12px;
416
+ font-weight: 600;
417
+ color: var(--text-primary);
418
+ font-size: 0.95em;
419
+ }
420
+
421
+ /* ==================== Output Sections ==================== */
422
+ .output {
423
+ background: var(--bg-light);
424
+ border-radius: var(--border-radius);
425
+ padding: 25px;
426
+ margin-top: 20px;
427
+ border: 1px solid var(--border-color);
428
+ }
429
+
430
+ .output.hidden {
431
+ display: none;
432
+ }
433
+
434
+ /* Stats Grid */
435
+ .stats-grid {
436
+ display: grid;
437
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
438
+ gap: 15px;
439
+ margin-bottom: 20px;
440
+ }
441
+
442
+ .stat-card {
443
+ background: var(--bg-white);
444
+ padding: 20px;
445
+ border-radius: var(--border-radius-sm);
446
+ text-align: center;
447
+ box-shadow: var(--shadow-sm);
448
+ border: 1px solid var(--border-color);
449
+ transition: var(--transition);
450
+ }
451
+
452
+ .stat-card:hover {
453
+ transform: translateY(-3px);
454
+ box-shadow: var(--shadow-md);
455
+ }
456
+
457
+ .stat-label {
458
+ font-size: 0.85em;
459
+ color: var(--text-secondary);
460
+ text-transform: uppercase;
461
+ letter-spacing: 0.5px;
462
+ font-weight: 600;
463
+ margin-bottom: 8px;
464
+ }
465
+
466
+ .stat-value {
467
+ font-size: 2em;
468
+ font-weight: 700;
469
+ background: var(--bg-gradient);
470
+ -webkit-background-clip: text;
471
+ -webkit-text-fill-color: transparent;
472
+ background-clip: text;
473
+ }
474
+
475
+ .distance-info {
476
+ background: var(--bg-white);
477
+ padding: 15px;
478
+ border-radius: var(--border-radius-sm);
479
+ border: 1px solid var(--border-color);
480
+ font-size: 0.95em;
481
+ }
482
+
483
+ /* ==================== Tables ==================== */
484
+ .table-wrapper {
485
+ overflow-x: auto;
486
+ border-radius: var(--border-radius-sm);
487
+ box-shadow: var(--shadow-sm);
488
+ margin-top: 15px;
489
+ }
490
+
491
+ .data-table {
492
+ width: 100%;
493
+ border-collapse: separate;
494
+ border-spacing: 0;
495
+ background: var(--bg-white);
496
+ border: 1px solid var(--border-color);
497
+ border-radius: var(--border-radius-sm);
498
+ overflow: hidden;
499
+ }
500
+
501
+ .data-table thead {
502
+ background: var(--bg-gradient);
503
+ color: white;
504
+ }
505
+
506
+ .data-table th,
507
+ .data-table td {
508
+ padding: 14px 18px;
509
+ text-align: left;
510
+ }
511
+
512
+ .data-table th {
513
+ font-weight: 600;
514
+ text-transform: uppercase;
515
+ font-size: 0.85em;
516
+ letter-spacing: 0.5px;
517
+ }
518
+
519
+ .data-table tbody tr {
520
+ transition: var(--transition);
521
+ border-bottom: 1px solid var(--border-color);
522
+ }
523
+
524
+ .data-table tbody tr:hover {
525
+ background: var(--bg-light);
526
+ }
527
+
528
+ .data-table tbody tr:last-child {
529
+ border-bottom: none;
530
+ }
531
+
532
+ .more-rows {
533
+ text-align: center;
534
+ font-style: italic;
535
+ color: var(--text-secondary);
536
+ background: var(--bg-light);
537
+ }
538
+
539
+ /* Distance Matrix */
540
+ .distance-matrix {
541
+ font-size: 0.85em;
542
+ }
543
+
544
+ .distance-matrix td {
545
+ text-align: center;
546
+ padding: 8px 12px;
547
+ }
548
+
549
+ .distance-matrix th {
550
+ text-align: center;
551
+ padding: 8px 12px;
552
+ }
553
+
554
+ .distance-matrix .diagonal {
555
+ background: var(--bg-light);
556
+ font-weight: 600;
557
+ color: var(--text-secondary);
558
+ }
559
+
560
+ /* Matrix Grid View */
561
+ .matrix-grid {
562
+ font-size: 0.75em;
563
+ }
564
+
565
+ .matrix-grid td {
566
+ text-align: center;
567
+ padding: 6px 10px;
568
+ font-family: 'Courier New', monospace;
569
+ }
570
+
571
+ .matrix-grid th {
572
+ text-align: center;
573
+ padding: 6px 10px;
574
+ font-size: 0.9em;
575
+ }
576
+
577
+ .matrix-grid .diagonal {
578
+ background: #fff3cd;
579
+ font-weight: 600;
580
+ }
581
+
582
+ /* ==================== Results Grid ==================== */
583
+ .results-grid {
584
+ display: grid;
585
+ grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
586
+ gap: 25px;
587
+ margin-top: 25px;
588
+ }
589
+
590
+ .result-box {
591
+ background: var(--bg-light);
592
+ border-radius: var(--border-radius);
593
+ padding: 25px;
594
+ box-shadow: var(--shadow-md);
595
+ border: 1px solid var(--border-color);
596
+ transition: var(--transition);
597
+ }
598
+
599
+ .result-box:hover {
600
+ box-shadow: var(--shadow-lg);
601
+ }
602
+
603
+ .result-box h3 {
604
+ color: var(--primary);
605
+ margin-bottom: 15px;
606
+ font-size: 1.2em;
607
+ font-weight: 600;
608
+ }
609
+
610
+ #tsne-plot,
611
+ #cost-plot,
612
+ #p-matrix-plot,
613
+ #q-matrix-plot {
614
+ min-height: 400px;
615
+ background: var(--bg-white);
616
+ border-radius: var(--border-radius-sm);
617
+ margin-top: 10px;
618
+ }
619
+
620
+ #coords-table {
621
+ max-height: 400px;
622
+ overflow-y: auto;
623
+ }
624
+
625
+ /* ==================== Upload Section ==================== */
626
+ .upload-section {
627
+ background: var(--bg-light);
628
+ border-radius: var(--border-radius);
629
+ padding: 25px;
630
+ margin-bottom: 25px;
631
+ border: 2px dashed var(--border-color);
632
+ transition: var(--transition);
633
+ }
634
+
635
+ .upload-section:hover {
636
+ border-color: var(--primary);
637
+ background: rgba(102, 126, 234, 0.02);
638
+ }
639
+
640
+ .upload-section h3 {
641
+ color: var(--primary);
642
+ margin-bottom: 15px;
643
+ font-size: 1.3em;
644
+ }
645
+
646
+ input[type="file"] {
647
+ display: none;
648
+ }
649
+
650
+ /* Dataset List */
651
+ .upload-list {
652
+ background: var(--bg-light);
653
+ border-radius: var(--border-radius);
654
+ padding: 25px;
655
+ border: 1px solid var(--border-color);
656
+ }
657
+
658
+ .dataset-list {
659
+ list-style: none;
660
+ }
661
+
662
+ .dataset-item {
663
+ padding: 15px 20px;
664
+ margin-bottom: 12px;
665
+ background: var(--bg-white);
666
+ border-radius: var(--border-radius-sm);
667
+ border-left: 4px solid var(--primary);
668
+ font-size: 1em;
669
+ box-shadow: var(--shadow-sm);
670
+ display: flex;
671
+ align-items: center;
672
+ gap: 12px;
673
+ transition: var(--transition);
674
+ }
675
+
676
+ .dataset-item:hover {
677
+ transform: translateX(5px);
678
+ box-shadow: var(--shadow-md);
679
+ }
680
+
681
+ .dataset-icon {
682
+ font-size: 1.5em;
683
+ }
684
+
685
+ .dataset-label {
686
+ flex: 1;
687
+ font-weight: 500;
688
+ }
689
+
690
+ .empty-state {
691
+ text-align: center;
692
+ padding: 40px;
693
+ color: var(--text-secondary);
694
+ font-size: 1.1em;
695
+ }
696
+
697
+ /* CSV Columns Selector */
698
+ #csv-columns-list {
699
+ background: var(--bg-white);
700
+ padding: 20px;
701
+ border-radius: var(--border-radius-sm);
702
+ max-height: 250px;
703
+ overflow-y: auto;
704
+ border: 2px solid var(--border-color);
705
+ margin-top: 10px;
706
+ }
707
+
708
+ .checkbox-label {
709
+ display: block;
710
+ padding: 12px;
711
+ cursor: pointer;
712
+ transition: var(--transition);
713
+ border-radius: var(--border-radius-sm);
714
+ font-weight: 500;
715
+ }
716
+
717
+ .checkbox-label:hover {
718
+ background: var(--bg-light);
719
+ }
720
+
721
+ .checkbox-label input[type="checkbox"] {
722
+ margin-right: 10px;
723
+ cursor: pointer;
724
+ width: 18px;
725
+ height: 18px;
726
+ }
727
+
728
+ /* Embed Status */
729
+ .embed-status {
730
+ margin-top: 15px;
731
+ padding: 12px 20px;
732
+ border-radius: var(--border-radius-sm);
733
+ font-weight: 500;
734
+ display: inline-block;
735
+ }
736
+
737
+ .embed-status.computing {
738
+ background: #fef3c7;
739
+ color: #92400e;
740
+ }
741
+
742
+ .embed-status.success {
743
+ background: #d1fae5;
744
+ color: #065f46;
745
+ }
746
+
747
+ .embed-status.error {
748
+ background: #fee2e2;
749
+ color: #991b1b;
750
+ }
751
+
752
+ /* ==================== Modal ==================== */
753
+ .modal {
754
+ position: fixed;
755
+ top: 0;
756
+ left: 0;
757
+ width: 100%;
758
+ height: 100%;
759
+ background: rgba(0, 0, 0, 0.8);
760
+ backdrop-filter: blur(5px);
761
+ display: flex;
762
+ justify-content: center;
763
+ align-items: center;
764
+ z-index: 1000;
765
+ animation: fadeIn 0.3s ease-out;
766
+ }
767
+
768
+ .modal.hidden {
769
+ display: none;
770
+ }
771
+
772
+ .modal-content {
773
+ background: var(--bg-white);
774
+ border-radius: var(--border-radius-lg);
775
+ padding: 40px;
776
+ max-width: 900px;
777
+ max-height: 90vh;
778
+ overflow: auto;
779
+ position: relative;
780
+ box-shadow: var(--shadow-xl);
781
+ animation: scaleIn 0.3s ease-out;
782
+ }
783
+
784
+ @keyframes scaleIn {
785
+ from {
786
+ opacity: 0;
787
+ transform: scale(0.9);
788
+ }
789
+ to {
790
+ opacity: 1;
791
+ transform: scale(1);
792
+ }
793
+ }
794
+
795
+ .modal-close {
796
+ position: absolute;
797
+ top: 20px;
798
+ right: 25px;
799
+ font-size: 2.5em;
800
+ cursor: pointer;
801
+ color: var(--text-secondary);
802
+ transition: var(--transition);
803
+ line-height: 1;
804
+ }
805
+
806
+ .modal-close:hover {
807
+ color: var(--error);
808
+ transform: rotate(90deg);
809
+ }
810
+
811
+ #modal-image {
812
+ max-width: 100%;
813
+ border-radius: var(--border-radius);
814
+ margin-top: 20px;
815
+ box-shadow: var(--shadow-lg);
816
+ }
817
+
818
+ /* ==================== Notifications ==================== */
819
+ #notification {
820
+ position: fixed;
821
+ top: -100px;
822
+ left: 50%;
823
+ transform: translateX(-50%);
824
+ background: var(--bg-white);
825
+ color: var(--text-primary);
826
+ padding: 16px 32px;
827
+ border-radius: var(--border-radius);
828
+ box-shadow: var(--shadow-xl);
829
+ z-index: 2000;
830
+ font-weight: 600;
831
+ min-width: 300px;
832
+ text-align: center;
833
+ transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
834
+ border-left: 4px solid var(--primary);
835
+ }
836
+
837
+ #notification.show {
838
+ top: 30px;
839
+ }
840
+
841
+ #notification.success {
842
+ border-left-color: var(--success);
843
+ background: #ecfdf5;
844
+ color: #065f46;
845
+ }
846
+
847
+ #notification.error {
848
+ border-left-color: var(--error);
849
+ background: #fef2f2;
850
+ color: #991b1b;
851
+ }
852
+
853
+ #notification.warning {
854
+ border-left-color: var(--warning);
855
+ background: #fffbeb;
856
+ color: #92400e;
857
+ }
858
+
859
+ #notification.info {
860
+ border-left-color: var(--info);
861
+ background: #eff6ff;
862
+ color: #1e40af;
863
+ }
864
+
865
+ /* ==================== Utility Classes ==================== */
866
+ .hidden {
867
+ display: none !important;
868
+ }
869
+
870
+ .text-center {
871
+ text-align: center;
872
+ }
873
+
874
+ /* ==================== Scrollbar Styling ==================== */
875
+ ::-webkit-scrollbar {
876
+ width: 12px;
877
+ height: 12px;
878
+ }
879
+
880
+ ::-webkit-scrollbar-track {
881
+ background: var(--bg-light);
882
+ border-radius: 10px;
883
+ }
884
+
885
+ ::-webkit-scrollbar-thumb {
886
+ background: var(--bg-gradient);
887
+ border-radius: 10px;
888
+ border: 2px solid var(--bg-light);
889
+ }
890
+
891
+ ::-webkit-scrollbar-thumb:hover {
892
+ background: linear-gradient(135deg, #5568d3 0%, #653a8a 100%);
893
+ }
894
+
895
+ /* ==================== Responsive Design ==================== */
896
+ @media (max-width: 1200px) {
897
+ .results-grid {
898
+ grid-template-columns: 1fr;
899
+ }
900
+ }
901
+
902
+ @media (max-width: 768px) {
903
+ body {
904
+ padding: 10px;
905
+ }
906
+
907
+ .container {
908
+ border-radius: var(--border-radius);
909
+ }
910
+
911
+ header {
912
+ padding: 25px 20px;
913
+ }
914
+
915
+ header h1 {
916
+ font-size: 2em;
917
+ }
918
+
919
+ .subtitle {
920
+ font-size: 1em;
921
+ }
922
+
923
+ .tabs {
924
+ flex-direction: column;
925
+ }
926
+
927
+ .tab-button {
928
+ padding: 15px 20px;
929
+ }
930
+
931
+ .tab-content {
932
+ padding: 20px;
933
+ }
934
+
935
+ .card {
936
+ padding: 20px;
937
+ }
938
+
939
+ .controls {
940
+ grid-template-columns: 1fr;
941
+ }
942
+
943
+ .results-grid {
944
+ grid-template-columns: 1fr;
945
+ }
946
+
947
+ .stats-grid {
948
+ grid-template-columns: 1fr;
949
+ }
950
+
951
+ .modal-content {
952
+ margin: 20px;
953
+ padding: 25px;
954
+ max-width: 90%;
955
+ }
956
+ }
957
+
958
+ @media (max-width: 480px) {
959
+ header h1 {
960
+ font-size: 1.6em;
961
+ }
962
+
963
+ .btn {
964
+ padding: 12px 20px;
965
+ font-size: 0.9em;
966
+ }
967
+
968
+ .button-group {
969
+ flex-direction: column;
970
+ }
971
+
972
+ .button-group .btn {
973
+ width: 100%;
974
+ }
975
+ }
976
+
977
+ /* ==================== Animations ==================== */
978
+ @keyframes pulse {
979
+ 0%, 100% {
980
+ opacity: 1;
981
+ }
982
+ 50% {
983
+ opacity: 0.5;
984
+ }
985
+ }
986
+
987
+ .loading {
988
+ animation: pulse 2s ease-in-out infinite;
989
+ }
990
+
991
+ /* ==================== Print Styles ==================== */
992
+ @media print {
993
+ body {
994
+ background: white;
995
+ padding: 0;
996
+ }
997
+
998
+ .container {
999
+ box-shadow: none;
1000
+ }
1001
+
1002
+ header {
1003
+ background: none;
1004
+ color: black;
1005
+ }
1006
+
1007
+ .tabs,
1008
+ .btn,
1009
+ .upload-section {
1010
+ display: none;
1011
+ }
1012
+ }