Upload 8 files
Browse files- Dockerfile +36 -0
- mcp_server.py +300 -0
- requirements-hf.txt +13 -0
- streamlit.py +1049 -0
- web/api-adapter.js +282 -0
- web/app.js +1253 -0
- web/index.html +327 -0
- 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">×</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 |
+
}
|