w4nn4b3M4ST3R commited on
Commit
e945653
·
1 Parent(s): 6d07ba3

separate to css, js, html

Browse files
Files changed (3) hide show
  1. app/index.html +3 -1344
  2. app/script.js +937 -0
  3. app/styles.css +395 -0
app/index.html CHANGED
@@ -19,410 +19,7 @@
19
  rel="stylesheet"
20
  />
21
 
22
- <style>
23
- /* --- CORE STYLES --- */
24
- body {
25
- background-color: #121212;
26
- color: #e0e0e0;
27
- font-family: "Outfit", sans-serif;
28
- overflow: hidden;
29
- }
30
-
31
- ::-webkit-scrollbar {
32
- width: 6px;
33
- height: 6px;
34
- }
35
- ::-webkit-scrollbar-track {
36
- background: #1a1a1a;
37
- }
38
- ::-webkit-scrollbar-thumb {
39
- background: #444;
40
- border-radius: 4px;
41
- }
42
- ::-webkit-scrollbar-thumb:hover {
43
- background: #666;
44
- }
45
-
46
- .tab-button {
47
- transition: all 0.2s ease;
48
- border-bottom: 2px solid transparent;
49
- position: relative;
50
- }
51
- .tab-button.active {
52
- color: #8b5cf6;
53
- border-bottom-color: #8b5cf6;
54
- background: rgba(139, 92, 246, 0.05);
55
- }
56
- .tab-button:hover {
57
- color: #fff;
58
- }
59
-
60
- .cluster-button {
61
- transition: all 0.2s ease;
62
- }
63
- .cluster-button.active {
64
- background-color: #8b5cf6;
65
- color: white;
66
- border-left: 3px solid #c4b5fd;
67
- }
68
-
69
- .thumbnail-card {
70
- background-color: #1e1e1e;
71
- border: 1px solid #333;
72
- transition: all 0.2s ease;
73
- position: relative;
74
- overflow: hidden;
75
- }
76
- .thumbnail-card:hover {
77
- border-color: #8b5cf6;
78
- transform: translateY(-2px);
79
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
80
- }
81
- .thumbnail-card.selected {
82
- border-color: #8b5cf6;
83
- background-color: rgba(139, 92, 246, 0.1);
84
- ring: 1px solid #8b5cf6;
85
- }
86
- .thumbnail-card.best-quality {
87
- border: 2px solid #10b981;
88
- box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
89
- }
90
-
91
- .quality-badge {
92
- position: absolute;
93
- top: 4px;
94
- right: 4px;
95
- padding: 2px 6px;
96
- border-radius: 4px;
97
- font-size: 10px;
98
- font-weight: 700;
99
- color: white;
100
- z-index: 10;
101
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
102
- font-family: "JetBrains Mono", monospace;
103
- cursor: help;
104
- }
105
- .best-badge {
106
- position: absolute;
107
- top: 4px;
108
- left: 4px;
109
- background: linear-gradient(135deg, #10b981, #059669);
110
- padding: 2px 6px;
111
- border-radius: 4px;
112
- font-size: 9px;
113
- font-weight: 800;
114
- color: white;
115
- z-index: 10;
116
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
117
- }
118
-
119
- .quality-details {
120
- position: absolute;
121
- top: 20px;
122
- right: 0;
123
- background: #1f2937;
124
- border: 1px solid #374151;
125
- border-radius: 6px;
126
- padding: 8px;
127
- font-size: 11px;
128
- white-space: nowrap;
129
- z-index: 100;
130
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
131
- display: none;
132
- min-width: 120px;
133
- pointer-events: none;
134
- }
135
- .quality-badge:hover .quality-details {
136
- display: block;
137
- }
138
- .quality-metric {
139
- display: flex;
140
- justify-content: space-between;
141
- gap: 12px;
142
- margin: 2px 0;
143
- }
144
- .quality-metric-label {
145
- color: #9ca3af;
146
- }
147
- .quality-metric-value {
148
- color: #e5e7eb;
149
- font-weight: 600;
150
- }
151
-
152
- /* --- CINEMATIC UNIVERSE MAP --- */
153
- #tab-universe {
154
- position: absolute;
155
- inset: 0;
156
- background: radial-gradient(
157
- ellipse at center,
158
- #0f0920 0%,
159
- #000000 100%
160
- );
161
- overflow: hidden;
162
- }
163
-
164
- #plotly-div {
165
- position: absolute;
166
- inset: 0;
167
- z-index: 1;
168
- background: transparent;
169
- transition: opacity 0.8s ease;
170
- }
171
-
172
- .star-layer {
173
- position: absolute;
174
- inset: 0;
175
- pointer-events: none;
176
- z-index: 0;
177
- opacity: 0.6;
178
- }
179
-
180
- .stars-small {
181
- width: 1px;
182
- height: 1px;
183
- background: transparent;
184
- box-shadow: 1744px 122px 2px rgba(255, 255, 255, 0.8),
185
- 134px 1321px 1px rgba(255, 255, 255, 0.6),
186
- 92px 859px 1px rgba(139, 92, 246, 0.4), 800px 600px 1px #fff,
187
- 1200px 400px 1px rgba(255, 255, 255, 0.7),
188
- 300px 1100px 1px rgba(167, 139, 250, 0.5);
189
- animation: animStar 150s linear infinite,
190
- twinkle 3s ease-in-out infinite;
191
- }
192
-
193
- .stars-medium {
194
- width: 2px;
195
- height: 2px;
196
- background: transparent;
197
- box-shadow: 122px 231px 3px rgba(139, 92, 246, 0.9),
198
- 421px 521px 2px rgba(255, 255, 255, 0.8),
199
- 900px 300px 2px rgba(167, 139, 250, 0.7),
200
- 600px 800px 2px rgba(255, 255, 255, 0.6);
201
- animation: animStar 100s linear infinite,
202
- glow 2s ease-in-out infinite alternate;
203
- }
204
-
205
- @keyframes animStar {
206
- from {
207
- transform: translateY(0px);
208
- }
209
- to {
210
- transform: translateY(-2000px);
211
- }
212
- }
213
-
214
- @keyframes twinkle {
215
- 0%,
216
- 100% {
217
- opacity: 0.6;
218
- }
219
- 50% {
220
- opacity: 1;
221
- }
222
- }
223
-
224
- @keyframes glow {
225
- 0% {
226
- filter: brightness(1);
227
- }
228
- 100% {
229
- filter: brightness(1.5) drop-shadow(0 0 4px rgba(139, 92, 246, 0.6));
230
- }
231
- }
232
-
233
- #tab-universe::before {
234
- content: "";
235
- position: absolute;
236
- inset: 0;
237
- background: radial-gradient(
238
- ellipse at 20% 30%,
239
- rgba(139, 92, 246, 0.15) 0%,
240
- transparent 50%
241
- ),
242
- radial-gradient(
243
- ellipse at 80% 70%,
244
- rgba(59, 130, 246, 0.12) 0%,
245
- transparent 50%
246
- ),
247
- radial-gradient(
248
- ellipse at 50% 50%,
249
- rgba(167, 139, 250, 0.08) 0%,
250
- transparent 60%
251
- );
252
- animation: nebulaPulse 8s ease-in-out infinite alternate;
253
- z-index: 0;
254
- pointer-events: none;
255
- }
256
-
257
- @keyframes nebulaPulse {
258
- 0% {
259
- opacity: 0.3;
260
- transform: scale(1);
261
- }
262
- 100% {
263
- opacity: 0.6;
264
- transform: scale(1.05);
265
- }
266
- }
267
-
268
- @keyframes floatIn {
269
- from {
270
- opacity: 0;
271
- transform: translateX(-50%) translateY(-20px);
272
- }
273
- to {
274
- opacity: 1;
275
- transform: translateX(-50%) translateY(0);
276
- }
277
- }
278
-
279
- .stat-box {
280
- background: #1e1e1e;
281
- border: 1px solid #333;
282
- border-radius: 12px;
283
- padding: 20px;
284
- position: relative;
285
- overflow: hidden;
286
- }
287
- .stat-box::after {
288
- content: "";
289
- position: absolute;
290
- top: 0;
291
- left: 0;
292
- width: 4px;
293
- height: 100%;
294
- background: #333;
295
- }
296
- .stat-box.purple::after {
297
- background: #8b5cf6;
298
- }
299
- .stat-box.green::after {
300
- background: #10b981;
301
- }
302
- .stat-box.red::after {
303
- background: #ef4444;
304
- }
305
- .stat-box.blue::after {
306
- background: #3b82f6;
307
- }
308
-
309
- .pipeline-step {
310
- position: relative;
311
- padding-left: 20px;
312
- border-left: 2px solid #333;
313
- padding-bottom: 20px;
314
- }
315
- .pipeline-step:last-child {
316
- border-left: 2px solid transparent;
317
- }
318
- .pipeline-step::before {
319
- content: "";
320
- position: absolute;
321
- left: -5px;
322
- top: 0;
323
- width: 8px;
324
- height: 8px;
325
- border-radius: 50%;
326
- background: #555;
327
- }
328
- .pipeline-step.done::before {
329
- background: #10b981;
330
- box-shadow: 0 0 8px #10b981;
331
- }
332
- .pipeline-step.done {
333
- border-left-color: #10b981;
334
- }
335
-
336
- .glass-panel-ui {
337
- background: rgba(20, 20, 25, 0.8);
338
- backdrop-filter: blur(16px);
339
- border: 1px solid rgba(255, 255, 255, 0.1);
340
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
341
- pointer-events: auto;
342
- z-index: 50;
343
- }
344
-
345
- .search-container {
346
- position: absolute;
347
- top: 24px;
348
- left: 50%;
349
- transform: translateX(-50%);
350
- width: 400px;
351
- max-width: 90%;
352
- border-radius: 99px;
353
- padding: 4px;
354
- display: flex;
355
- transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
356
- animation: floatIn 0.8s ease-out;
357
- }
358
-
359
- .search-container:focus-within {
360
- background: rgba(20, 20, 25, 0.95);
361
- border-color: #8b5cf6;
362
- box-shadow: 0 0 30px rgba(139, 92, 246, 0.4),
363
- 0 0 60px rgba(139, 92, 246, 0.2),
364
- inset 0 0 20px rgba(139, 92, 246, 0.1);
365
- transform: translateX(-50%) scale(1.02);
366
- }
367
-
368
- .search-input {
369
- background: transparent;
370
- border: none;
371
- color: white;
372
- padding: 8px 16px;
373
- width: 100%;
374
- outline: none;
375
- font-size: 14px;
376
- }
377
-
378
- .bottom-controls {
379
- position: absolute;
380
- bottom: 30px;
381
- left: 50%;
382
- transform: translateX(-50%);
383
- border-radius: 16px;
384
- padding: 8px 16px;
385
- display: flex;
386
- gap: 16px;
387
- align-items: center;
388
- animation: floatIn 1s ease-out 0.3s backwards;
389
- transition: all 0.3s ease;
390
- }
391
-
392
- .bottom-controls:hover {
393
- box-shadow: 0 8px 40px rgba(0, 0, 0, 0.8),
394
- 0 0 20px rgba(139, 92, 246, 0.2);
395
- transform: translateX(-50%) translateY(-2px);
396
- }
397
-
398
- .toggle-switch {
399
- appearance: none;
400
- width: 36px;
401
- height: 20px;
402
- background: #334155;
403
- border-radius: 20px;
404
- position: relative;
405
- transition: 0.3s;
406
- cursor: pointer;
407
- }
408
- .toggle-switch::after {
409
- content: "";
410
- position: absolute;
411
- top: 2px;
412
- left: 2px;
413
- width: 16px;
414
- height: 16px;
415
- background: #fff;
416
- border-radius: 50%;
417
- transition: 0.3s;
418
- }
419
- .toggle-switch:checked {
420
- background: #8b5cf6;
421
- }
422
- .toggle-switch:checked::after {
423
- transform: translateX(16px);
424
- }
425
- </style>
426
  </head>
427
  <body class="h-screen flex flex-col m-0 p-0 bg-[#121212]">
428
  <header
@@ -1066,944 +663,6 @@
1066
  </div>
1067
  </div>
1068
 
1069
- <script>
1070
- const API_URL = "/api";
1071
-
1072
- const toggleConfigBtn = document.getElementById("toggle-config-btn");
1073
- const controlPanel = document.getElementById("control-panel");
1074
- const configArrow = document.getElementById("config-arrow");
1075
- let isConfigOpen = true;
1076
-
1077
- toggleConfigBtn.addEventListener("click", () => {
1078
- isConfigOpen = !isConfigOpen;
1079
- if (isConfigOpen) {
1080
- controlPanel.style.maxHeight = "200px";
1081
- controlPanel.style.opacity = "1";
1082
- controlPanel.style.padding = "16px";
1083
- configArrow.style.transform = "rotate(0deg)";
1084
- document.getElementById("config-status-text").textContent =
1085
- "Hide Config";
1086
- } else {
1087
- controlPanel.style.maxHeight = "0px";
1088
- controlPanel.style.opacity = "0";
1089
- controlPanel.style.padding = "0px";
1090
- configArrow.style.transform = "rotate(-90deg)";
1091
- document.getElementById("config-status-text").textContent =
1092
- "Show Config";
1093
- }
1094
- });
1095
-
1096
- let uploadedFiles = null;
1097
- let currentSessionId = null;
1098
- let currentGroups = {};
1099
- let qualityScores = {};
1100
- let currentClusterName = null;
1101
- let universeState = {
1102
- data: [],
1103
- isRotating: true,
1104
- originalColors: [],
1105
- lastSearchColors: null,
1106
- lastSearchSizes: null,
1107
- lastSearchLineColors: null,
1108
- lastSearchLineWidths: null,
1109
- isSearchActive: false,
1110
- };
1111
-
1112
- const loadingOverlay = document.getElementById("loading-overlay");
1113
- const loadingText = document.getElementById("loading-text");
1114
- const loadingBar = document.getElementById("loading-bar");
1115
-
1116
- document
1117
- .getElementById("image-folder-input")
1118
- .addEventListener("change", (e) => {
1119
- if (e.target.files.length > 0) {
1120
- uploadedFiles = e.target.files;
1121
- document.getElementById(
1122
- "image-folder-display"
1123
- ).value = `${e.target.files.length} files selected`;
1124
- }
1125
- });
1126
-
1127
- document
1128
- .getElementById("start-clustering-btn")
1129
- .addEventListener("click", async () => {
1130
- if (!uploadedFiles || uploadedFiles.length === 0)
1131
- return alert("Please select a folder first.");
1132
- if (isConfigOpen) toggleConfigBtn.click();
1133
-
1134
- loadingOverlay.classList.remove("hidden");
1135
- loadingText.textContent = "Uploading images...";
1136
- loadingBar.style.width = "10%";
1137
-
1138
- const fd = new FormData();
1139
- fd.append("algorithm", document.getElementById("algorithm").value);
1140
- let count = 0;
1141
- for (const f of uploadedFiles) {
1142
- fd.append("files", f, f.webkitRelativePath || f.name);
1143
- count++;
1144
- if (count % 20 === 0)
1145
- loadingBar.style.width = `${
1146
- 10 + (count / uploadedFiles.length) * 40
1147
- }%`;
1148
- }
1149
-
1150
- loadingText.textContent = "Analyzing (Semantic + Perceptual)...";
1151
- loadingBar.style.width = "60%";
1152
-
1153
- try {
1154
- const res = await fetch(`${API_URL}/run-clustering`, {
1155
- method: "POST",
1156
- body: fd,
1157
- });
1158
- if (!res.ok) throw new Error((await res.json()).detail);
1159
- const data = await res.json();
1160
-
1161
- currentSessionId = data.session_id;
1162
- qualityScores = data.quality_scores || {};
1163
-
1164
- loadingBar.style.width = "100%";
1165
- populateUI(data);
1166
-
1167
- setTimeout(() => loadingOverlay.classList.add("hidden"), 500);
1168
- } catch (e) {
1169
- alert("Error: " + e.message);
1170
- loadingOverlay.classList.add("hidden");
1171
- }
1172
- });
1173
-
1174
- function populateUI(data) {
1175
- currentGroups = data.results.groups || {};
1176
- const results = data.results;
1177
- const total = results.total_images || 0;
1178
- const unique = Object.keys(currentGroups).length;
1179
- const dupes = total - unique;
1180
-
1181
- renderStatsDashboard(data, unique, dupes);
1182
-
1183
- document.getElementById("summary-content").classList.add("hidden");
1184
- document.getElementById("summary-visuals").classList.remove("hidden");
1185
- document.getElementById("stat-total").textContent = total;
1186
- document.getElementById("stat-duplicates").textContent = dupes;
1187
- document.getElementById("stat-clusters").textContent = unique;
1188
- document.getElementById("stat-saving").textContent = total
1189
- ? ((dupes / total) * 100).toFixed(1) + "%"
1190
- : "0%";
1191
- renderCharts(unique, dupes, currentGroups);
1192
-
1193
- renderClusterList();
1194
- document.getElementById("download-btn").classList.remove("hidden");
1195
- document.getElementById("download-btn").onclick = () =>
1196
- (window.location.href = `${API_URL}/download-results/${currentSessionId}`);
1197
- document.getElementById("delete-group-btn").classList.remove("hidden");
1198
-
1199
- if (data.universe_map) renderUniverseMap(data.universe_map);
1200
-
1201
- document.querySelector('[data-tab="browser"]').click();
1202
- }
1203
-
1204
- function renderStatsDashboard(data, unique, dupes) {
1205
- document.getElementById("stats-empty").classList.add("hidden");
1206
- document.getElementById("stats-content").classList.remove("hidden");
1207
-
1208
- const total = data.results.total_images;
1209
- const saved = total ? ((dupes / total) * 100).toFixed(1) : 0;
1210
- const perf = data.performance || {};
1211
-
1212
- document.getElementById("d-total").textContent = total;
1213
- document.getElementById("d-dupes").textContent = dupes;
1214
- document.getElementById("d-saved").textContent = `${saved}%`;
1215
- document.getElementById("d-clusters").textContent = unique;
1216
-
1217
- const ctx1 = document.getElementById("chart-dist-new").getContext("2d");
1218
- if (window.c1) window.c1.destroy();
1219
- const bins = [0, 0, 0];
1220
- Object.values(currentGroups).forEach((g) => {
1221
- if (g.length <= 2) bins[0]++;
1222
- else if (g.length <= 5) bins[1]++;
1223
- else bins[2]++;
1224
- });
1225
- window.c1 = new Chart(ctx1, {
1226
- type: "bar",
1227
- data: {
1228
- labels: ["Small (2)", "Medium (3-5)", "Large (6+)"],
1229
- datasets: [
1230
- {
1231
- label: "Count",
1232
- data: bins,
1233
- backgroundColor: "#8b5cf6",
1234
- borderRadius: 4,
1235
- },
1236
- ],
1237
- },
1238
- options: {
1239
- maintainAspectRatio: false,
1240
- plugins: { legend: { display: false } },
1241
- scales: {
1242
- y: { grid: { color: "#333" }, ticks: { color: "#aaa" } },
1243
- x: { grid: { display: false }, ticks: { color: "#aaa" } },
1244
- },
1245
- },
1246
- });
1247
-
1248
- const ctx2 = document
1249
- .getElementById("chart-ratio-new")
1250
- .getContext("2d");
1251
- if (window.c2) window.c2.destroy();
1252
- window.c2 = new Chart(ctx2, {
1253
- type: "doughnut",
1254
- data: {
1255
- labels: ["Unique", "Duplicate"],
1256
- datasets: [
1257
- {
1258
- data: [unique, dupes],
1259
- backgroundColor: ["#10b981", "#ef4444"],
1260
- borderWidth: 0,
1261
- },
1262
- ],
1263
- },
1264
- options: {
1265
- maintainAspectRatio: false,
1266
- plugins: {
1267
- legend: { position: "right", labels: { color: "#ccc" } },
1268
- },
1269
- },
1270
- });
1271
-
1272
- const steps = [
1273
- { name: "Feature Extraction", time: perf.extraction_time },
1274
- { name: "Semantic Clustering", time: perf.stage1_cluster_time },
1275
- { name: "Perceptual Hashing", time: perf.stage2_cluster_time },
1276
- { name: "Quality Scoring", time: perf.quality_scoring_time },
1277
- ];
1278
- const pipeHTML = steps
1279
- .map(
1280
- (s) =>
1281
- `<div class="pipeline-step done"><div class="flex justify-between mb-1"><span class="text-xs font-bold text-white">${
1282
- s.name
1283
- }</span><span class="text-xs text-emerald-400">${s.time?.toFixed(
1284
- 2
1285
- )}s</span></div></div>`
1286
- )
1287
- .join("");
1288
- document.getElementById("pipeline-steps").innerHTML = pipeHTML;
1289
-
1290
- document.getElementById(
1291
- "log-console"
1292
- ).innerText = `[INFO] Session ID: ${
1293
- data.session_id
1294
- }\n[INFO] Total Execution Time: ${perf.total_processing_time?.toFixed(
1295
- 2
1296
- )}s\n[INFO] Algorithm: ${
1297
- document.getElementById("algorithm").value
1298
- }\n[SUCCESS] Analysis complete. Found ${unique} clusters.`;
1299
- }
1300
-
1301
- // === CINEMATIC UNIVERSE MAP ===
1302
- function renderUniverseMap(points) {
1303
- if (!points.length) return;
1304
- universeState.data = points;
1305
- document.getElementById("universe-empty").classList.add("hidden");
1306
- document.getElementById("search-ui").classList.remove("hidden");
1307
- document.getElementById("map-controls").classList.remove("hidden");
1308
-
1309
- const palette = [
1310
- "#ef4444",
1311
- "#3b82f6",
1312
- "#10b981",
1313
- "#f59e0b",
1314
- "#8b5cf6",
1315
- "#ec4899",
1316
- "#06b6d4",
1317
- "#84cc16",
1318
- "#f97316",
1319
- "#6366f1",
1320
- "#14b8a6",
1321
- "#d946ef",
1322
- "#eab308",
1323
- "#f43f5e",
1324
- "#a855f7",
1325
- ];
1326
-
1327
- const clusterColorMap = {};
1328
- let colorIndex = 0;
1329
-
1330
- points.forEach((p) => {
1331
- if (p.cluster !== "Noise/Unique" && !clusterColorMap[p.cluster]) {
1332
- clusterColorMap[p.cluster] = palette[colorIndex % palette.length];
1333
- colorIndex++;
1334
- }
1335
- });
1336
-
1337
- const colors = [];
1338
- points.forEach((p) => {
1339
- if (p.cluster === "Noise/Unique") {
1340
- colors.push("#444444");
1341
- } else {
1342
- colors.push(clusterColorMap[p.cluster]);
1343
- }
1344
- });
1345
- universeState.originalColors = colors;
1346
- drawPlot(colors, null, false);
1347
- if (document.getElementById("toggle-rotate").checked) startRotation();
1348
- }
1349
-
1350
- function drawPlot(
1351
- colorArray,
1352
- sizes = null,
1353
- isSearchMode = false,
1354
- customLineColors = null,
1355
- customLineWidths = null
1356
- ) {
1357
- const points = universeState.data;
1358
- const x = points.map((p) => p.x),
1359
- y = points.map((p) => p.y),
1360
- z = points.map((p) => p.z);
1361
- const s =
1362
- sizes || points.map((p) => (p.cluster === "Noise/Unique" ? 8 : 16));
1363
-
1364
- const traces = [];
1365
-
1366
- if (document.getElementById("toggle-lines").checked && !isSearchMode) {
1367
- const clusters = {};
1368
- points.forEach((p) => {
1369
- if (!clusters[p.cluster]) clusters[p.cluster] = [];
1370
- clusters[p.cluster].push(p);
1371
- });
1372
-
1373
- const lx = [],
1374
- ly = [],
1375
- lz = [];
1376
- Object.entries(clusters).forEach(([name, group]) => {
1377
- if (name === "Noise/Unique" || group.length < 2) return;
1378
- let cx = 0,
1379
- cy = 0,
1380
- cz = 0;
1381
- group.forEach((g) => {
1382
- cx += g.x;
1383
- cy += g.y;
1384
- cz += g.z;
1385
- });
1386
- cx /= group.length;
1387
- cy /= group.length;
1388
- cz /= group.length;
1389
-
1390
- group.forEach((p) => {
1391
- lx.push(p.x, cx, null);
1392
- ly.push(p.y, cy, null);
1393
- lz.push(p.z, cz, null);
1394
- });
1395
- });
1396
-
1397
- traces.push({
1398
- x: lx,
1399
- y: ly,
1400
- z: lz,
1401
- mode: "lines",
1402
- type: "scatter3d",
1403
- line: {
1404
- color: "rgba(255,255,255,0.6)",
1405
- width: 4,
1406
- },
1407
- hoverinfo: "none",
1408
- });
1409
- }
1410
-
1411
- traces.push({
1412
- x: x,
1413
- y: y,
1414
- z: z,
1415
- mode: "markers",
1416
- type: "scatter3d",
1417
- marker: {
1418
- size: s,
1419
- color: colorArray,
1420
- symbol: "circle",
1421
- opacity: 1,
1422
- line: {
1423
- color:
1424
- isSearchMode && customLineColors
1425
- ? customLineColors
1426
- : "rgba(255,255,255,0.3)",
1427
- width: isSearchMode && customLineWidths ? customLineWidths : 1,
1428
- },
1429
- },
1430
- hoverinfo: "none",
1431
- });
1432
-
1433
- const layout = {
1434
- margin: { l: 0, r: 0, b: 0, t: 0 },
1435
- paper_bgcolor: "rgba(0,0,0,0)",
1436
- plot_bgcolor: "rgba(0,0,0,0)",
1437
- showlegend: false,
1438
- scene: {
1439
- xaxis: {
1440
- visible: false,
1441
- autorange: true,
1442
- },
1443
- yaxis: {
1444
- visible: false,
1445
- autorange: true,
1446
- },
1447
- zaxis: {
1448
- visible: false,
1449
- autorange: true,
1450
- },
1451
- dragmode: "orbit",
1452
- camera: {
1453
- eye: { x: 0.5, y: 0.5, z: 0.5 },
1454
- center: { x: 0, y: 0, z: 0 },
1455
- },
1456
- aspectmode: "cube",
1457
- },
1458
- };
1459
-
1460
- Plotly.newPlot("plotly-div", traces, layout, {
1461
- displayModeBar: false,
1462
- scrollZoom: true,
1463
- }).then(setupInteractions);
1464
- }
1465
-
1466
- function setupInteractions() {
1467
- const plot = document.getElementById("plotly-div");
1468
- const tooltip = document.getElementById("universe-tooltip");
1469
-
1470
- plot.on("plotly_hover", (d) => {
1471
- if (universeState.isRotating) stopRotation();
1472
- const curveNum = d.points[0].curveNumber;
1473
- const pointIndex = document.getElementById("toggle-lines").checked
1474
- ? 1
1475
- : 0;
1476
- if (curveNum !== pointIndex) return;
1477
-
1478
- const i = d.points[0].pointNumber;
1479
- const p = universeState.data[i];
1480
-
1481
- document.getElementById(
1482
- "tooltip-img"
1483
- ).src = `${API_URL}/results/${currentSessionId}/clusters/${p.path}`;
1484
- document.getElementById("tooltip-name").textContent = p.filename;
1485
- document.getElementById("tooltip-cluster").textContent = p.cluster;
1486
- document.getElementById("tooltip-score").textContent = p.quality
1487
- ? p.quality.toFixed(0)
1488
- : "N/A";
1489
-
1490
- tooltip.classList.remove("hidden");
1491
- document.onmousemove = (e) => {
1492
- tooltip.style.left = e.clientX + 20 + "px";
1493
- tooltip.style.top = e.clientY + 20 + "px";
1494
- };
1495
- });
1496
-
1497
- plot.on("plotly_unhover", () => {
1498
- tooltip.classList.add("hidden");
1499
- document.onmousemove = null;
1500
- if (document.getElementById("toggle-rotate").checked) startRotation();
1501
- });
1502
-
1503
- plot.on("plotly_click", (d) => {
1504
- const pointIndex = document.getElementById("toggle-lines").checked
1505
- ? 1
1506
- : 0;
1507
- if (d.points[0].curveNumber !== pointIndex) return;
1508
- const p = universeState.data[d.points[0].pointNumber];
1509
- if (p.cluster && p.cluster !== "Noise/Unique") {
1510
- document.querySelector('[data-tab="browser"]').click();
1511
- loadCluster(p.cluster);
1512
- }
1513
- });
1514
- }
1515
-
1516
- document.getElementById("btn-search").onclick = performSearch;
1517
- document.getElementById("search-input").onkeypress = (e) => {
1518
- if (e.key === "Enter") performSearch();
1519
- };
1520
-
1521
- async function performSearch() {
1522
- const q = document.getElementById("search-input").value.trim();
1523
- if (!q) {
1524
- universeState.isSearchActive = false;
1525
- drawPlot(universeState.originalColors, null, false);
1526
- return;
1527
- }
1528
-
1529
- const btn = document.getElementById("btn-search");
1530
- const originalText = btn.textContent;
1531
- btn.textContent = "🔍";
1532
- btn.style.opacity = "0.6";
1533
-
1534
- try {
1535
- const res = await fetch(`${API_URL}/semantic-search`, {
1536
- method: "POST",
1537
- headers: { "Content-Type": "application/json" },
1538
- body: JSON.stringify({ query: q, top_k: 20 }),
1539
- });
1540
-
1541
- if (!res.ok) throw new Error("Search failed");
1542
-
1543
- const data = await res.json();
1544
- if (data.results?.length) {
1545
- const map = {};
1546
- data.results.forEach((r) => (map[r.filename] = r.score));
1547
-
1548
- const colors = [];
1549
- const sizes = [];
1550
- const lineColors = [];
1551
- const lineWidths = [];
1552
-
1553
- let best = null;
1554
- let max = -1;
1555
-
1556
- universeState.data.forEach((p) => {
1557
- const fname = p.filename.split("/").pop();
1558
- if (map[fname] || map[p.filename]) {
1559
- colors.push("#fbbf24");
1560
- sizes.push(15);
1561
- lineColors.push("rgba(251, 191, 36, 0.6)");
1562
- lineWidths.push(8);
1563
- const score = map[fname] || map[p.filename];
1564
- if (score > max) {
1565
- max = score;
1566
- best = p;
1567
- }
1568
- } else {
1569
- colors.push("rgba(255, 255, 255, 0.1)");
1570
- sizes.push(10);
1571
- lineColors.push("transparent");
1572
- lineWidths.push(0);
1573
- }
1574
- });
1575
- universeState.isSearchActive = true;
1576
- universeState.lastSearchColors = colors;
1577
- universeState.lastSearchSizes = sizes;
1578
- universeState.lastSearchLineColors = lineColors;
1579
- universeState.lastSearchLineWidths = lineWidths;
1580
-
1581
- drawPlot(colors, sizes, true, lineColors, lineWidths);
1582
-
1583
- if (best) {
1584
- stopRotation();
1585
- setTimeout(() => {
1586
- Plotly.animate(
1587
- "plotly-div",
1588
- {
1589
- layout: {
1590
- "scene.camera": {
1591
- eye: {
1592
- x: best.x * 0.5,
1593
- y: best.y * 0.5,
1594
- z: best.z * 0.5,
1595
- },
1596
- center: { x: best.x, y: best.y, z: best.z },
1597
- },
1598
- },
1599
- },
1600
- {
1601
- transition: { duration: 1200, easing: "cubic-in-out" },
1602
- frame: { duration: 1200 },
1603
- }
1604
- );
1605
- }, 100);
1606
- }
1607
- } else {
1608
- alert("No matching images found.");
1609
- }
1610
- } catch (e) {
1611
- console.error(e);
1612
- alert("Search Failed: " + e.message);
1613
- }
1614
-
1615
- btn.textContent = originalText;
1616
- btn.style.opacity = "1";
1617
- }
1618
-
1619
- function syncUniverseMap(deletedPaths) {
1620
- if (!universeState.data.length) return;
1621
- universeState.data = universeState.data.filter(
1622
- (p) => !deletedPaths.includes(p.path)
1623
- );
1624
- renderUniverseMap(universeState.data);
1625
- }
1626
-
1627
- function startRotation() {
1628
- if (!universeState.isRotating) {
1629
- universeState.isRotating = true;
1630
- rotateLoop();
1631
- }
1632
- }
1633
-
1634
- function stopRotation() {
1635
- universeState.isRotating = false;
1636
- cancelAnimationFrame(universeState.raf);
1637
- }
1638
-
1639
- function rotateLoop() {
1640
- if (!universeState.isRotating) return;
1641
- universeState.angle = (universeState.angle || 0) + 0.0015;
1642
- const r = 2.2;
1643
- const eyeX = r * Math.cos(universeState.angle);
1644
- const eyeY = r * Math.sin(universeState.angle);
1645
- const eyeZ = r * 0.9;
1646
-
1647
- Plotly.relayout("plotly-div", {
1648
- "scene.camera.eye": { x: eyeX, y: eyeY, z: eyeZ },
1649
- });
1650
- universeState.raf = requestAnimationFrame(rotateLoop);
1651
- }
1652
-
1653
- document.getElementById("toggle-rotate").onchange = () => {
1654
- if (universeState.isSearchActive) {
1655
- drawPlot(
1656
- universeState.lastSearchColors,
1657
- universeState.lastSearchSizes,
1658
- true,
1659
- universeState.lastSearchLineColors,
1660
- universeState.lastSearchLineWidths
1661
- );
1662
- } else {
1663
- drawPlot(universeState.originalColors, null, false);
1664
- }
1665
- };
1666
- document.getElementById("toggle-lines").onchange = () => {
1667
- const currentColors = universeState.data.map((_, i) => {
1668
- const plotData = document.getElementById("plotly-div").data;
1669
- if (plotData && plotData[plotData.length - 1]) {
1670
- return plotData[plotData.length - 1].marker.color[i];
1671
- }
1672
- return universeState.originalColors[i];
1673
- });
1674
-
1675
- const isSearchMode = currentColors.some((c) => c === "#fbbf24");
1676
-
1677
- if (isSearchMode) {
1678
- const sizes = universeState.data.map((p, i) =>
1679
- currentColors[i] === "#fbbf24" ? 24 : 0
1680
- );
1681
- drawPlot(currentColors, sizes, true);
1682
- } else {
1683
- drawPlot(universeState.originalColors, null, false);
1684
- }
1685
- };
1686
-
1687
- document.querySelectorAll(".tab-button").forEach((btn) => {
1688
- btn.addEventListener("click", () => {
1689
- document
1690
- .querySelectorAll(".tab-button")
1691
- .forEach((b) => b.classList.remove("active"));
1692
- document
1693
- .querySelectorAll(".tab-content")
1694
- .forEach((c) => c.classList.add("hidden"));
1695
- btn.classList.add("active");
1696
- document
1697
- .getElementById(`tab-${btn.dataset.tab}`)
1698
- .classList.remove("hidden");
1699
-
1700
- if (btn.dataset.tab === "universe") {
1701
- setTimeout(() => {
1702
- Plotly.Plots.resize("plotly-div");
1703
- if (document.getElementById("toggle-rotate").checked)
1704
- startRotation();
1705
- }, 50);
1706
- } else {
1707
- stopRotation();
1708
- universeState.isRotating = false;
1709
- }
1710
- });
1711
- });
1712
-
1713
- function renderClusterList() {
1714
- const list = document.getElementById("cluster-list");
1715
- list.innerHTML = "";
1716
- Object.entries(currentGroups)
1717
- .sort((a, b) => b[1].length - a[1].length)
1718
- .forEach(([name, files]) => {
1719
- const btn = document.createElement("button");
1720
- btn.className =
1721
- "cluster-button w-full text-left p-2.5 rounded text-gray-400 hover:bg-[#333] text-xs font-medium mb-1";
1722
- btn.innerHTML = `<span class="text-white font-bold">${name}</span> <span class="text-gray-500 ml-1">(${files.length})</span>`;
1723
- btn.dataset.clusterName = name;
1724
- btn.onclick = () => loadCluster(name);
1725
- list.appendChild(btn);
1726
- });
1727
- }
1728
-
1729
- function loadCluster(name) {
1730
- currentClusterName = name;
1731
- document
1732
- .querySelectorAll(".cluster-button")
1733
- .forEach((b) => b.classList.remove("active"));
1734
- document
1735
- .querySelector(`[data-cluster-name="${name}"]`)
1736
- ?.classList.add("active");
1737
-
1738
- const gallery = document.getElementById("thumbnail-gallery");
1739
- gallery.innerHTML = "";
1740
- document.getElementById(
1741
- "thumbnail-header"
1742
- ).textContent = `Cluster Content: ${name}`;
1743
- const q = qualityScores[name]?.images || [];
1744
-
1745
- document.getElementById("delete-btn").disabled = false;
1746
- document.getElementById("move-btn").disabled = false;
1747
- document.getElementById("smart-cleanup-btn").disabled = false;
1748
-
1749
- currentGroups[name].forEach((path) => {
1750
- const url = `${API_URL}/results/${currentSessionId}/clusters/${path}`;
1751
- const info = q.find((i) => i.path === path);
1752
- const isBest = info?.is_best;
1753
-
1754
- const div = document.createElement("div");
1755
- div.className = `thumbnail-card rounded p-2 flex flex-col relative group ${
1756
- isBest ? "best-quality" : ""
1757
- }`;
1758
- div.dataset.path = path;
1759
- div.innerHTML = `
1760
- <input type="checkbox" class="mb-1 z-20 accent-violet-500 cursor-pointer">
1761
- ${isBest ? '<div class="best-badge">BEST</div>' : ""}
1762
- ${
1763
- info
1764
- ? `<div class="quality-badge" style="background:${
1765
- info.quality_color
1766
- }">${info.scores.total.toFixed(0)}
1767
- <div class="quality-details"><div class="quality-metric"><span class="quality-metric-label">Res</span><span class="quality-metric-value">${
1768
- info.scores.resolution
1769
- }</span></div><div class="quality-metric"><span class="quality-metric-label">Sharp</span><span class="quality-metric-value">${
1770
- info.scores.sharpness
1771
- }</span></div></div>
1772
- </div>`
1773
- : ""
1774
- }
1775
- <div class="relative overflow-hidden rounded aspect-square bg-black">
1776
- <img src="${url}" class="w-full h-full object-contain transition-transform duration-300 group-hover:scale-110 cursor-zoom-in" onclick="document.getElementById('modal-image').src='${url}';document.getElementById('image-modal').classList.remove('hidden')">
1777
- </div>
1778
- <div class="text-[10px] text-gray-400 truncate mt-2 font-mono text-center">${path
1779
- .split("/")
1780
- .pop()}</div>
1781
- `;
1782
- div.querySelector("input").onclick = (e) => {
1783
- e.stopPropagation();
1784
- div.classList.toggle("selected", e.target.checked);
1785
- };
1786
- gallery.appendChild(div);
1787
- });
1788
- }
1789
-
1790
- document.getElementById("delete-btn").onclick = async () => {
1791
- const paths = Array.from(
1792
- document.querySelectorAll(".thumbnail-card.selected")
1793
- ).map((c) => c.dataset.path);
1794
- if (!paths.length || !confirm(`Delete ${paths.length} images?`)) return;
1795
- try {
1796
- const res = await fetch(`${API_URL}/delete-images`, {
1797
- method: "POST",
1798
- headers: { "Content-Type": "application/json" },
1799
- body: JSON.stringify({
1800
- session_id: currentSessionId,
1801
- image_paths: paths,
1802
- }),
1803
- });
1804
- const data = await res.json();
1805
- data.deleted.forEach((p) =>
1806
- document.querySelector(`[data-path="${p}"]`)?.remove()
1807
- );
1808
- syncUniverseMap(data.deleted);
1809
- } catch (e) {
1810
- alert(e);
1811
- }
1812
- };
1813
-
1814
- document.getElementById("smart-cleanup-btn").onclick = async () => {
1815
- const sel = document.querySelectorAll(".thumbnail-card.selected");
1816
- if (sel.length !== 1) return alert("Select exactly ONE image to keep.");
1817
- const keep = sel[0].dataset.path;
1818
- if (!confirm(`Keep ${keep.split("/").pop()} and delete others?`))
1819
- return;
1820
- try {
1821
- const res = await fetch(`${API_URL}/smart-cleanup`, {
1822
- method: "POST",
1823
- headers: { "Content-Type": "application/json" },
1824
- body: JSON.stringify({
1825
- session_id: currentSessionId,
1826
- cluster_name: currentClusterName,
1827
- image_to_keep: keep,
1828
- }),
1829
- });
1830
- const data = await res.json();
1831
- const old = currentGroups[currentClusterName];
1832
- const deleted = old.filter((p) => p !== data.image_kept);
1833
- syncUniverseMap(deleted);
1834
- currentGroups[currentClusterName] = [data.image_kept];
1835
- loadCluster(currentClusterName);
1836
- renderClusterList();
1837
- } catch (e) {
1838
- alert(e);
1839
- }
1840
- };
1841
-
1842
- document.getElementById("delete-group-btn").onclick = async () => {
1843
- if (!confirm(`Delete group ${currentClusterName}?`)) return;
1844
- const res = await fetch(`${API_URL}/delete-group`, {
1845
- method: "POST",
1846
- headers: { "Content-Type": "application/json" },
1847
- body: JSON.stringify({
1848
- session_id: currentSessionId,
1849
- cluster_name: currentClusterName,
1850
- }),
1851
- });
1852
- if (res.ok) {
1853
- delete currentGroups[currentClusterName];
1854
- currentClusterName = null;
1855
- renderClusterList();
1856
- document.getElementById("thumbnail-gallery").innerHTML = "";
1857
- }
1858
- };
1859
-
1860
- document.getElementById("move-btn").onclick = () => {
1861
- const paths = Array.from(
1862
- document.querySelectorAll(".thumbnail-card.selected")
1863
- ).map((c) => c.dataset.path);
1864
- if (!paths.length) return alert("Select images to move.");
1865
- const sel = document.getElementById("move-cluster-select");
1866
- sel.innerHTML = '<option value="__NEW__">-- New Cluster --</option>';
1867
- Object.keys(currentGroups).forEach((g) => {
1868
- if (g !== currentClusterName)
1869
- sel.innerHTML += `<option value="${g}">${g}</option>`;
1870
- });
1871
- document.getElementById("move-modal").classList.remove("hidden");
1872
- };
1873
-
1874
- document.getElementById("move-cancel-btn").onclick = () =>
1875
- document.getElementById("move-modal").classList.add("hidden");
1876
-
1877
- document.getElementById("move-cluster-select").onchange = (e) =>
1878
- document
1879
- .getElementById("move-new-cluster-input-group")
1880
- .classList.toggle("hidden", e.target.value !== "__NEW__");
1881
-
1882
- document.getElementById("move-confirm-btn").onclick = async () => {
1883
- const paths = Array.from(
1884
- document.querySelectorAll(".thumbnail-card.selected")
1885
- ).map((c) => c.dataset.path);
1886
- let dest = document.getElementById("move-cluster-select").value;
1887
- if (dest === "__NEW__")
1888
- dest = document.getElementById("move-new-cluster-name").value.trim();
1889
- if (!dest) return alert("Invalid name");
1890
-
1891
- document.getElementById("move-modal").classList.add("hidden");
1892
- try {
1893
- const res = await fetch(`${API_URL}/move-images`, {
1894
- method: "POST",
1895
- headers: { "Content-Type": "application/json" },
1896
- body: JSON.stringify({
1897
- session_id: currentSessionId,
1898
- image_paths: paths,
1899
- destination_cluster: dest,
1900
- }),
1901
- });
1902
- const data = await res.json();
1903
- currentGroups[currentClusterName] = currentGroups[
1904
- currentClusterName
1905
- ].filter((p) => !paths.includes(p));
1906
- if (!currentGroups[dest]) currentGroups[dest] = [];
1907
- currentGroups[dest].push(
1908
- ...data.moved.map((p) =>
1909
- p.includes("/") ? p : `${dest}/${p.split("/").pop()}`
1910
- )
1911
- );
1912
- loadCluster(currentClusterName);
1913
- renderClusterList();
1914
-
1915
- universeState.data.forEach((p) => {
1916
- const fileName = p.path.split("/").pop();
1917
- if (paths.some((movedPath) => movedPath.endsWith(fileName))) {
1918
- p.cluster = dest;
1919
- }
1920
- });
1921
- renderUniverseMap(universeState.data);
1922
- } catch (e) {
1923
- alert(e);
1924
- }
1925
- };
1926
-
1927
- function renderCharts(unique, dupes, groups) {
1928
- const ctx1 = document.getElementById("summary-chart").getContext("2d");
1929
- if (window.c3) window.c3.destroy();
1930
- window.c3 = new Chart(ctx1, {
1931
- type: "doughnut",
1932
- data: {
1933
- labels: ["Unique", "Duplicate"],
1934
- datasets: [
1935
- {
1936
- data: [unique, dupes],
1937
- backgroundColor: ["#3b82f6", "#ef4444"],
1938
- borderWidth: 0,
1939
- },
1940
- ],
1941
- },
1942
- options: {
1943
- maintainAspectRatio: false,
1944
- plugins: {
1945
- legend: { position: "bottom", labels: { color: "#ccc" } },
1946
- },
1947
- },
1948
- });
1949
-
1950
- const bins = [0, 0, 0];
1951
- Object.values(groups).forEach((g) => {
1952
- if (g.length <= 2) bins[0]++;
1953
- else if (g.length <= 5) bins[1]++;
1954
- else bins[2]++;
1955
- });
1956
- const ctx2 = document
1957
- .getElementById("distribution-chart")
1958
- .getContext("2d");
1959
- if (window.c4) window.c4.destroy();
1960
- window.c4 = new Chart(ctx2, {
1961
- type: "bar",
1962
- data: {
1963
- labels: ["Small", "Medium", "Large"],
1964
- datasets: [
1965
- { label: "Clusters", data: bins, backgroundColor: "#8b5cf6" },
1966
- ],
1967
- },
1968
- options: {
1969
- maintainAspectRatio: false,
1970
- plugins: { legend: { display: false } },
1971
- scales: {
1972
- y: { grid: { color: "#333" } },
1973
- x: { grid: { display: false } },
1974
- },
1975
- },
1976
- });
1977
- }
1978
-
1979
- document.getElementById("select-all-btn").onclick = () =>
1980
- document.querySelectorAll(".thumbnail-card").forEach((c) => {
1981
- c.classList.add("selected");
1982
- c.querySelector("input").checked = true;
1983
- });
1984
-
1985
- document.getElementById("deselect-all-btn").onclick = () =>
1986
- document.querySelectorAll(".thumbnail-card").forEach((c) => {
1987
- c.classList.remove("selected");
1988
- c.querySelector("input").checked = false;
1989
- });
1990
-
1991
- document.getElementById("keep-best-btn").onclick = () => {
1992
- const best = qualityScores[currentClusterName]?.images.find(
1993
- (i) => i.is_best
1994
- );
1995
- if (best) {
1996
- document
1997
- .querySelectorAll(".thumbnail-card")
1998
- .forEach((c) => c.classList.remove("selected"));
1999
- document
2000
- .querySelector(`[data-path="${best.path}"]`)
2001
- ?.classList.add("selected");
2002
- document.querySelector(
2003
- `[data-path="${best.path}"] input`
2004
- ).checked = true;
2005
- }
2006
- };
2007
- </script>
2008
  </body>
2009
- </html>
 
19
  rel="stylesheet"
20
  />
21
 
22
+ <link rel="stylesheet" href="styles.css" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  </head>
24
  <body class="h-screen flex flex-col m-0 p-0 bg-[#121212]">
25
  <header
 
663
  </div>
664
  </div>
665
 
666
+ <script src="script.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  </body>
668
+ </html>
app/script.js ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_URL = "/api";
2
+
3
+ const toggleConfigBtn = document.getElementById("toggle-config-btn");
4
+ const controlPanel = document.getElementById("control-panel");
5
+ const configArrow = document.getElementById("config-arrow");
6
+ let isConfigOpen = true;
7
+
8
+ toggleConfigBtn.addEventListener("click", () => {
9
+ isConfigOpen = !isConfigOpen;
10
+ if (isConfigOpen) {
11
+ controlPanel.style.maxHeight = "200px";
12
+ controlPanel.style.opacity = "1";
13
+ controlPanel.style.padding = "16px";
14
+ configArrow.style.transform = "rotate(0deg)";
15
+ document.getElementById("config-status-text").textContent =
16
+ "Hide Config";
17
+ } else {
18
+ controlPanel.style.maxHeight = "0px";
19
+ controlPanel.style.opacity = "0";
20
+ controlPanel.style.padding = "0px";
21
+ configArrow.style.transform = "rotate(-90deg)";
22
+ document.getElementById("config-status-text").textContent =
23
+ "Show Config";
24
+ }
25
+ });
26
+
27
+ let uploadedFiles = null;
28
+ let currentSessionId = null;
29
+ let currentGroups = {};
30
+ let qualityScores = {};
31
+ let currentClusterName = null;
32
+ let universeState = {
33
+ data: [],
34
+ isRotating: true,
35
+ originalColors: [],
36
+ lastSearchColors: null,
37
+ lastSearchSizes: null,
38
+ lastSearchLineColors: null,
39
+ lastSearchLineWidths: null,
40
+ isSearchActive: false,
41
+ };
42
+
43
+ const loadingOverlay = document.getElementById("loading-overlay");
44
+ const loadingText = document.getElementById("loading-text");
45
+ const loadingBar = document.getElementById("loading-bar");
46
+
47
+ document
48
+ .getElementById("image-folder-input")
49
+ .addEventListener("change", (e) => {
50
+ if (e.target.files.length > 0) {
51
+ uploadedFiles = e.target.files;
52
+ document.getElementById(
53
+ "image-folder-display"
54
+ ).value = `${e.target.files.length} files selected`;
55
+ }
56
+ });
57
+
58
+ document
59
+ .getElementById("start-clustering-btn")
60
+ .addEventListener("click", async () => {
61
+ if (!uploadedFiles || uploadedFiles.length === 0)
62
+ return alert("Please select a folder first.");
63
+ if (isConfigOpen) toggleConfigBtn.click();
64
+
65
+ loadingOverlay.classList.remove("hidden");
66
+ loadingText.textContent = "Uploading images...";
67
+ loadingBar.style.width = "10%";
68
+
69
+ const fd = new FormData();
70
+ fd.append("algorithm", document.getElementById("algorithm").value);
71
+ let count = 0;
72
+ for (const f of uploadedFiles) {
73
+ fd.append("files", f, f.webkitRelativePath || f.name);
74
+ count++;
75
+ if (count % 20 === 0)
76
+ loadingBar.style.width = `${
77
+ 10 + (count / uploadedFiles.length) * 40
78
+ }%`;
79
+ }
80
+
81
+ loadingText.textContent = "Analyzing (Semantic + Perceptual)...";
82
+ loadingBar.style.width = "60%";
83
+
84
+ try {
85
+ const res = await fetch(`${API_URL}/run-clustering`, {
86
+ method: "POST",
87
+ body: fd,
88
+ });
89
+ if (!res.ok) throw new Error((await res.json()).detail);
90
+ const data = await res.json();
91
+
92
+ currentSessionId = data.session_id;
93
+ qualityScores = data.quality_scores || {};
94
+
95
+ loadingBar.style.width = "100%";
96
+ populateUI(data);
97
+
98
+ setTimeout(() => loadingOverlay.classList.add("hidden"), 500);
99
+ } catch (e) {
100
+ alert("Error: " + e.message);
101
+ loadingOverlay.classList.add("hidden");
102
+ }
103
+ });
104
+
105
+ function populateUI(data) {
106
+ currentGroups = data.results.groups || {};
107
+ const results = data.results;
108
+ const total = results.total_images || 0;
109
+ const unique = Object.keys(currentGroups).length;
110
+ const dupes = total - unique;
111
+
112
+ renderStatsDashboard(data, unique, dupes);
113
+
114
+ document.getElementById("summary-content").classList.add("hidden");
115
+ document.getElementById("summary-visuals").classList.remove("hidden");
116
+ document.getElementById("stat-total").textContent = total;
117
+ document.getElementById("stat-duplicates").textContent = dupes;
118
+ document.getElementById("stat-clusters").textContent = unique;
119
+ document.getElementById("stat-saving").textContent = total
120
+ ? ((dupes / total) * 100).toFixed(1) + "%"
121
+ : "0%";
122
+ renderCharts(unique, dupes, currentGroups);
123
+
124
+ renderClusterList();
125
+ document.getElementById("download-btn").classList.remove("hidden");
126
+ document.getElementById("download-btn").onclick = () =>
127
+ (window.location.href = `${API_URL}/download-results/${currentSessionId}`);
128
+ document.getElementById("delete-group-btn").classList.remove("hidden");
129
+
130
+ if (data.universe_map) renderUniverseMap(data.universe_map);
131
+
132
+ document.querySelector('[data-tab="browser"]').click();
133
+ }
134
+
135
+ function renderStatsDashboard(data, unique, dupes) {
136
+ document.getElementById("stats-empty").classList.add("hidden");
137
+ document.getElementById("stats-content").classList.remove("hidden");
138
+
139
+ const total = data.results.total_images;
140
+ const saved = total ? ((dupes / total) * 100).toFixed(1) : 0;
141
+ const perf = data.performance || {};
142
+
143
+ document.getElementById("d-total").textContent = total;
144
+ document.getElementById("d-dupes").textContent = dupes;
145
+ document.getElementById("d-saved").textContent = `${saved}%`;
146
+ document.getElementById("d-clusters").textContent = unique;
147
+
148
+ const ctx1 = document.getElementById("chart-dist-new").getContext("2d");
149
+ if (window.c1) window.c1.destroy();
150
+ const bins = [0, 0, 0];
151
+ Object.values(currentGroups).forEach((g) => {
152
+ if (g.length <= 2) bins[0]++;
153
+ else if (g.length <= 5) bins[1]++;
154
+ else bins[2]++;
155
+ });
156
+ window.c1 = new Chart(ctx1, {
157
+ type: "bar",
158
+ data: {
159
+ labels: ["Small (2)", "Medium (3-5)", "Large (6+)"],
160
+ datasets: [
161
+ {
162
+ label: "Count",
163
+ data: bins,
164
+ backgroundColor: "#8b5cf6",
165
+ borderRadius: 4,
166
+ },
167
+ ],
168
+ },
169
+ options: {
170
+ maintainAspectRatio: false,
171
+ plugins: { legend: { display: false } },
172
+ scales: {
173
+ y: { grid: { color: "#333" }, ticks: { color: "#aaa" } },
174
+ x: { grid: { display: false }, ticks: { color: "#aaa" } },
175
+ },
176
+ },
177
+ });
178
+
179
+ const ctx2 = document
180
+ .getElementById("chart-ratio-new")
181
+ .getContext("2d");
182
+ if (window.c2) window.c2.destroy();
183
+ window.c2 = new Chart(ctx2, {
184
+ type: "doughnut",
185
+ data: {
186
+ labels: ["Unique", "Duplicate"],
187
+ datasets: [
188
+ {
189
+ data: [unique, dupes],
190
+ backgroundColor: ["#10b981", "#ef4444"],
191
+ borderWidth: 0,
192
+ },
193
+ ],
194
+ },
195
+ options: {
196
+ maintainAspectRatio: false,
197
+ plugins: {
198
+ legend: { position: "right", labels: { color: "#ccc" } },
199
+ },
200
+ },
201
+ });
202
+
203
+ const steps = [
204
+ { name: "Feature Extraction", time: perf.extraction_time },
205
+ { name: "Semantic Clustering", time: perf.stage1_cluster_time },
206
+ { name: "Perceptual Hashing", time: perf.stage2_cluster_time },
207
+ { name: "Quality Scoring", time: perf.quality_scoring_time },
208
+ ];
209
+ const pipeHTML = steps
210
+ .map(
211
+ (s) =>
212
+ `<div class="pipeline-step done"><div class="flex justify-between mb-1"><span class="text-xs font-bold text-white">${
213
+ s.name
214
+ }</span><span class="text-xs text-emerald-400">${s.time?.toFixed(
215
+ 2
216
+ )}s</span></div></div>`
217
+ )
218
+ .join("");
219
+ document.getElementById("pipeline-steps").innerHTML = pipeHTML;
220
+
221
+ document.getElementById(
222
+ "log-console"
223
+ ).innerText = `[INFO] Session ID: ${
224
+ data.session_id
225
+ }\n[INFO] Total Execution Time: ${perf.total_processing_time?.toFixed(
226
+ 2
227
+ )}s\n[INFO] Algorithm: ${
228
+ document.getElementById("algorithm").value
229
+ }\n[SUCCESS] Analysis complete. Found ${unique} clusters.`;
230
+ }
231
+
232
+ // === CINEMATIC UNIVERSE MAP ===
233
+ function renderUniverseMap(points) {
234
+ if (!points.length) return;
235
+ universeState.data = points;
236
+ document.getElementById("universe-empty").classList.add("hidden");
237
+ document.getElementById("search-ui").classList.remove("hidden");
238
+ document.getElementById("map-controls").classList.remove("hidden");
239
+
240
+ const palette = [
241
+ "#ef4444",
242
+ "#3b82f6",
243
+ "#10b981",
244
+ "#f59e0b",
245
+ "#8b5cf6",
246
+ "#ec4899",
247
+ "#06b6d4",
248
+ "#84cc16",
249
+ "#f97316",
250
+ "#6366f1",
251
+ "#14b8a6",
252
+ "#d946ef",
253
+ "#eab308",
254
+ "#f43f5e",
255
+ "#a855f7",
256
+ ];
257
+
258
+ const clusterColorMap = {};
259
+ let colorIndex = 0;
260
+
261
+ points.forEach((p) => {
262
+ if (p.cluster !== "Noise/Unique" && !clusterColorMap[p.cluster]) {
263
+ clusterColorMap[p.cluster] = palette[colorIndex % palette.length];
264
+ colorIndex++;
265
+ }
266
+ });
267
+
268
+ const colors = [];
269
+ points.forEach((p) => {
270
+ if (p.cluster === "Noise/Unique") {
271
+ colors.push("#444444");
272
+ } else {
273
+ colors.push(clusterColorMap[p.cluster]);
274
+ }
275
+ });
276
+ universeState.originalColors = colors;
277
+ drawPlot(colors, null, false);
278
+ if (document.getElementById("toggle-rotate").checked) startRotation();
279
+ }
280
+
281
+ function drawPlot(
282
+ colorArray,
283
+ sizes = null,
284
+ isSearchMode = false,
285
+ customLineColors = null,
286
+ customLineWidths = null
287
+ ) {
288
+ const points = universeState.data;
289
+ const x = points.map((p) => p.x),
290
+ y = points.map((p) => p.y),
291
+ z = points.map((p) => p.z);
292
+ const s =
293
+ sizes || points.map((p) => (p.cluster === "Noise/Unique" ? 8 : 16));
294
+
295
+ const traces = [];
296
+
297
+ if (document.getElementById("toggle-lines").checked && !isSearchMode) {
298
+ const clusters = {};
299
+ points.forEach((p) => {
300
+ if (!clusters[p.cluster]) clusters[p.cluster] = [];
301
+ clusters[p.cluster].push(p);
302
+ });
303
+
304
+ const lx = [],
305
+ ly = [],
306
+ lz = [];
307
+ Object.entries(clusters).forEach(([name, group]) => {
308
+ if (name === "Noise/Unique" || group.length < 2) return;
309
+ let cx = 0,
310
+ cy = 0,
311
+ cz = 0;
312
+ group.forEach((g) => {
313
+ cx += g.x;
314
+ cy += g.y;
315
+ cz += g.z;
316
+ });
317
+ cx /= group.length;
318
+ cy /= group.length;
319
+ cz /= group.length;
320
+
321
+ group.forEach((p) => {
322
+ lx.push(p.x, cx, null);
323
+ ly.push(p.y, cy, null);
324
+ lz.push(p.z, cz, null);
325
+ });
326
+ });
327
+
328
+ traces.push({
329
+ x: lx,
330
+ y: ly,
331
+ z: lz,
332
+ mode: "lines",
333
+ type: "scatter3d",
334
+ line: {
335
+ color: "rgba(255,255,255,0.6)",
336
+ width: 4,
337
+ },
338
+ hoverinfo: "none",
339
+ });
340
+ }
341
+
342
+ traces.push({
343
+ x: x,
344
+ y: y,
345
+ z: z,
346
+ mode: "markers",
347
+ type: "scatter3d",
348
+ marker: {
349
+ size: s,
350
+ color: colorArray,
351
+ symbol: "circle",
352
+ opacity: 1,
353
+ line: {
354
+ color:
355
+ isSearchMode && customLineColors
356
+ ? customLineColors
357
+ : "rgba(255,255,255,0.3)",
358
+ width: isSearchMode && customLineWidths ? customLineWidths : 1,
359
+ },
360
+ },
361
+ hoverinfo: "none",
362
+ });
363
+
364
+ const layout = {
365
+ margin: { l: 0, r: 0, b: 0, t: 0 },
366
+ paper_bgcolor: "rgba(0,0,0,0)",
367
+ plot_bgcolor: "rgba(0,0,0,0)",
368
+ showlegend: false,
369
+ scene: {
370
+ xaxis: {
371
+ visible: false,
372
+ autorange: true,
373
+ },
374
+ yaxis: {
375
+ visible: false,
376
+ autorange: true,
377
+ },
378
+ zaxis: {
379
+ visible: false,
380
+ autorange: true,
381
+ },
382
+ dragmode: "orbit",
383
+ camera: {
384
+ eye: { x: 0.5, y: 0.5, z: 0.5 },
385
+ center: { x: 0, y: 0, z: 0 },
386
+ },
387
+ aspectmode: "cube",
388
+ },
389
+ };
390
+
391
+ Plotly.newPlot("plotly-div", traces, layout, {
392
+ displayModeBar: false,
393
+ scrollZoom: true,
394
+ }).then(setupInteractions);
395
+ }
396
+
397
+ function setupInteractions() {
398
+ const plot = document.getElementById("plotly-div");
399
+ const tooltip = document.getElementById("universe-tooltip");
400
+
401
+ plot.on("plotly_hover", (d) => {
402
+ if (universeState.isRotating) stopRotation();
403
+ const curveNum = d.points[0].curveNumber;
404
+ const pointIndex = document.getElementById("toggle-lines").checked
405
+ ? 1
406
+ : 0;
407
+ if (curveNum !== pointIndex) return;
408
+
409
+ const i = d.points[0].pointNumber;
410
+ const p = universeState.data[i];
411
+
412
+ document.getElementById(
413
+ "tooltip-img"
414
+ ).src = `${API_URL}/results/${currentSessionId}/clusters/${p.path}`;
415
+ document.getElementById("tooltip-name").textContent = p.filename;
416
+ document.getElementById("tooltip-cluster").textContent = p.cluster;
417
+ document.getElementById("tooltip-score").textContent = p.quality
418
+ ? p.quality.toFixed(0)
419
+ : "N/A";
420
+
421
+ tooltip.classList.remove("hidden");
422
+ document.onmousemove = (e) => {
423
+ tooltip.style.left = e.clientX + 20 + "px";
424
+ tooltip.style.top = e.clientY + 20 + "px";
425
+ };
426
+ });
427
+
428
+ plot.on("plotly_unhover", () => {
429
+ tooltip.classList.add("hidden");
430
+ document.onmousemove = null;
431
+ if (document.getElementById("toggle-rotate").checked) startRotation();
432
+ });
433
+
434
+ plot.on("plotly_click", (d) => {
435
+ const pointIndex = document.getElementById("toggle-lines").checked
436
+ ? 1
437
+ : 0;
438
+ if (d.points[0].curveNumber !== pointIndex) return;
439
+ const p = universeState.data[d.points[0].pointNumber];
440
+ if (p.cluster && p.cluster !== "Noise/Unique") {
441
+ document.querySelector('[data-tab="browser"]').click();
442
+ loadCluster(p.cluster);
443
+ }
444
+ });
445
+ }
446
+
447
+ document.getElementById("btn-search").onclick = performSearch;
448
+ document.getElementById("search-input").onkeypress = (e) => {
449
+ if (e.key === "Enter") performSearch();
450
+ };
451
+
452
+ async function performSearch() {
453
+ const q = document.getElementById("search-input").value.trim();
454
+ if (!q) {
455
+ universeState.isSearchActive = false;
456
+ drawPlot(universeState.originalColors, null, false);
457
+ return;
458
+ }
459
+
460
+ const btn = document.getElementById("btn-search");
461
+ const originalText = btn.textContent;
462
+ btn.textContent = "🔍";
463
+ btn.style.opacity = "0.6";
464
+
465
+ try {
466
+ const res = await fetch(`${API_URL}/semantic-search`, {
467
+ method: "POST",
468
+ headers: { "Content-Type": "application/json" },
469
+ body: JSON.stringify({ query: q, top_k: 20 }),
470
+ });
471
+
472
+ if (!res.ok) throw new Error("Search failed");
473
+
474
+ const data = await res.json();
475
+ if (data.results?.length) {
476
+ const map = {};
477
+ data.results.forEach((r) => (map[r.filename] = r.score));
478
+
479
+ const colors = [];
480
+ const sizes = [];
481
+ const lineColors = [];
482
+ const lineWidths = [];
483
+
484
+ let best = null;
485
+ let max = -1;
486
+
487
+ universeState.data.forEach((p) => {
488
+ const fname = p.filename.split("/").pop();
489
+ if (map[fname] || map[p.filename]) {
490
+ colors.push("#fbbf24");
491
+ sizes.push(15);
492
+ lineColors.push("rgba(251, 191, 36, 0.6)");
493
+ lineWidths.push(8);
494
+ const score = map[fname] || map[p.filename];
495
+ if (score > max) {
496
+ max = score;
497
+ best = p;
498
+ }
499
+ } else {
500
+ colors.push("rgba(255, 255, 255, 0.1)");
501
+ sizes.push(10);
502
+ lineColors.push("transparent");
503
+ lineWidths.push(0);
504
+ }
505
+ });
506
+ universeState.isSearchActive = true;
507
+ universeState.lastSearchColors = colors;
508
+ universeState.lastSearchSizes = sizes;
509
+ universeState.lastSearchLineColors = lineColors;
510
+ universeState.lastSearchLineWidths = lineWidths;
511
+
512
+ drawPlot(colors, sizes, true, lineColors, lineWidths);
513
+
514
+ if (best) {
515
+ stopRotation();
516
+ setTimeout(() => {
517
+ Plotly.animate(
518
+ "plotly-div",
519
+ {
520
+ layout: {
521
+ "scene.camera": {
522
+ eye: {
523
+ x: best.x * 0.5,
524
+ y: best.y * 0.5,
525
+ z: best.z * 0.5,
526
+ },
527
+ center: { x: best.x, y: best.y, z: best.z },
528
+ },
529
+ },
530
+ },
531
+ {
532
+ transition: { duration: 1200, easing: "cubic-in-out" },
533
+ frame: { duration: 1200 },
534
+ }
535
+ );
536
+ }, 100);
537
+ }
538
+ } else {
539
+ alert("No matching images found.");
540
+ }
541
+ } catch (e) {
542
+ console.error(e);
543
+ alert("Search Failed: " + e.message);
544
+ }
545
+
546
+ btn.textContent = originalText;
547
+ btn.style.opacity = "1";
548
+ }
549
+
550
+ function syncUniverseMap(deletedPaths) {
551
+ if (!universeState.data.length) return;
552
+ universeState.data = universeState.data.filter(
553
+ (p) => !deletedPaths.includes(p.path)
554
+ );
555
+ renderUniverseMap(universeState.data);
556
+ }
557
+
558
+ function startRotation() {
559
+ if (!universeState.isRotating) {
560
+ universeState.isRotating = true;
561
+ rotateLoop();
562
+ }
563
+ }
564
+
565
+ function stopRotation() {
566
+ universeState.isRotating = false;
567
+ cancelAnimationFrame(universeState.raf);
568
+ }
569
+
570
+ function rotateLoop() {
571
+ if (!universeState.isRotating) return;
572
+ universeState.angle = (universeState.angle || 0) + 0.0015;
573
+ const r = 2.2;
574
+ const eyeX = r * Math.cos(universeState.angle);
575
+ const eyeY = r * Math.sin(universeState.angle);
576
+ const eyeZ = r * 0.9;
577
+
578
+ Plotly.relayout("plotly-div", {
579
+ "scene.camera.eye": { x: eyeX, y: eyeY, z: eyeZ },
580
+ });
581
+ universeState.raf = requestAnimationFrame(rotateLoop);
582
+ }
583
+
584
+ document.getElementById("toggle-rotate").onchange = () => {
585
+ if (universeState.isSearchActive) {
586
+ drawPlot(
587
+ universeState.lastSearchColors,
588
+ universeState.lastSearchSizes,
589
+ true,
590
+ universeState.lastSearchLineColors,
591
+ universeState.lastSearchLineWidths
592
+ );
593
+ } else {
594
+ drawPlot(universeState.originalColors, null, false);
595
+ }
596
+ };
597
+ document.getElementById("toggle-lines").onchange = () => {
598
+ const currentColors = universeState.data.map((_, i) => {
599
+ const plotData = document.getElementById("plotly-div").data;
600
+ if (plotData && plotData[plotData.length - 1]) {
601
+ return plotData[plotData.length - 1].marker.color[i];
602
+ }
603
+ return universeState.originalColors[i];
604
+ });
605
+
606
+ const isSearchMode = currentColors.some((c) => c === "#fbbf24");
607
+
608
+ if (isSearchMode) {
609
+ const sizes = universeState.data.map((p, i) =>
610
+ currentColors[i] === "#fbbf24" ? 24 : 0
611
+ );
612
+ drawPlot(currentColors, sizes, true);
613
+ } else {
614
+ drawPlot(universeState.originalColors, null, false);
615
+ }
616
+ };
617
+
618
+ document.querySelectorAll(".tab-button").forEach((btn) => {
619
+ btn.addEventListener("click", () => {
620
+ document
621
+ .querySelectorAll(".tab-button")
622
+ .forEach((b) => b.classList.remove("active"));
623
+ document
624
+ .querySelectorAll(".tab-content")
625
+ .forEach((c) => c.classList.add("hidden"));
626
+ btn.classList.add("active");
627
+ document
628
+ .getElementById(`tab-${btn.dataset.tab}`)
629
+ .classList.remove("hidden");
630
+
631
+ if (btn.dataset.tab === "universe") {
632
+ setTimeout(() => {
633
+ Plotly.Plots.resize("plotly-div");
634
+ if (document.getElementById("toggle-rotate").checked)
635
+ startRotation();
636
+ }, 50);
637
+ } else {
638
+ stopRotation();
639
+ universeState.isRotating = false;
640
+ }
641
+ });
642
+ });
643
+
644
+ function renderClusterList() {
645
+ const list = document.getElementById("cluster-list");
646
+ list.innerHTML = "";
647
+ Object.entries(currentGroups)
648
+ .sort((a, b) => b[1].length - a[1].length)
649
+ .forEach(([name, files]) => {
650
+ const btn = document.createElement("button");
651
+ btn.className =
652
+ "cluster-button w-full text-left p-2.5 rounded text-gray-400 hover:bg-[#333] text-xs font-medium mb-1";
653
+ btn.innerHTML = `<span class="text-white font-bold">${name}</span> <span class="text-gray-500 ml-1">(${files.length})</span>`;
654
+ btn.dataset.clusterName = name;
655
+ btn.onclick = () => loadCluster(name);
656
+ list.appendChild(btn);
657
+ });
658
+ }
659
+
660
+ function loadCluster(name) {
661
+ currentClusterName = name;
662
+ document
663
+ .querySelectorAll(".cluster-button")
664
+ .forEach((b) => b.classList.remove("active"));
665
+ document
666
+ .querySelector(`[data-cluster-name="${name}"]`)
667
+ ?.classList.add("active");
668
+
669
+ const gallery = document.getElementById("thumbnail-gallery");
670
+ gallery.innerHTML = "";
671
+ document.getElementById(
672
+ "thumbnail-header"
673
+ ).textContent = `Cluster Content: ${name}`;
674
+ const q = qualityScores[name]?.images || [];
675
+
676
+ document.getElementById("delete-btn").disabled = false;
677
+ document.getElementById("move-btn").disabled = false;
678
+ document.getElementById("smart-cleanup-btn").disabled = false;
679
+
680
+ currentGroups[name].forEach((path) => {
681
+ const url = `${API_URL}/results/${currentSessionId}/clusters/${path}`;
682
+ const info = q.find((i) => i.path === path);
683
+ const isBest = info?.is_best;
684
+
685
+ const div = document.createElement("div");
686
+ div.className = `thumbnail-card rounded p-2 flex flex-col relative group ${
687
+ isBest ? "best-quality" : ""
688
+ }`;
689
+ div.dataset.path = path;
690
+ div.innerHTML = `
691
+ <input type="checkbox" class="mb-1 z-20 accent-violet-500 cursor-pointer">
692
+ ${isBest ? '<div class="best-badge">BEST</div>' : ""}
693
+ ${
694
+ info
695
+ ? `<div class="quality-badge" style="background:${
696
+ info.quality_color
697
+ }">${info.scores.total.toFixed(0)}
698
+ <div class="quality-details"><div class="quality-metric"><span class="quality-metric-label">Res</span><span class="quality-metric-value">${
699
+ info.scores.resolution
700
+ }</span></div><div class="quality-metric"><span class="quality-metric-label">Sharp</span><span class="quality-metric-value">${
701
+ info.scores.sharpness
702
+ }</span></div></div>
703
+ </div>`
704
+ : ""
705
+ }
706
+ <div class="relative overflow-hidden rounded aspect-square bg-black">
707
+ <img src="${url}" class="w-full h-full object-contain transition-transform duration-300 group-hover:scale-110 cursor-zoom-in" onclick="document.getElementById('modal-image').src='${url}';document.getElementById('image-modal').classList.remove('hidden')">
708
+ </div>
709
+ <div class="text-[10px] text-gray-400 truncate mt-2 font-mono text-center">${path
710
+ .split("/")
711
+ .pop()}</div>
712
+ `;
713
+ div.querySelector("input").onclick = (e) => {
714
+ e.stopPropagation();
715
+ div.classList.toggle("selected", e.target.checked);
716
+ };
717
+ gallery.appendChild(div);
718
+ });
719
+ }
720
+
721
+ document.getElementById("delete-btn").onclick = async () => {
722
+ const paths = Array.from(
723
+ document.querySelectorAll(".thumbnail-card.selected")
724
+ ).map((c) => c.dataset.path);
725
+ if (!paths.length || !confirm(`Delete ${paths.length} images?`)) return;
726
+ try {
727
+ const res = await fetch(`${API_URL}/delete-images`, {
728
+ method: "POST",
729
+ headers: { "Content-Type": "application/json" },
730
+ body: JSON.stringify({
731
+ session_id: currentSessionId,
732
+ image_paths: paths,
733
+ }),
734
+ });
735
+ const data = await res.json();
736
+ data.deleted.forEach((p) =>
737
+ document.querySelector(`[data-path="${p}"]`)?.remove()
738
+ );
739
+ syncUniverseMap(data.deleted);
740
+ } catch (e) {
741
+ alert(e);
742
+ }
743
+ };
744
+
745
+ document.getElementById("smart-cleanup-btn").onclick = async () => {
746
+ const sel = document.querySelectorAll(".thumbnail-card.selected");
747
+ if (sel.length !== 1) return alert("Select exactly ONE image to keep.");
748
+ const keep = sel[0].dataset.path;
749
+ if (!confirm(`Keep ${keep.split("/").pop()} and delete others?`))
750
+ return;
751
+ try {
752
+ const res = await fetch(`${API_URL}/smart-cleanup`, {
753
+ method: "POST",
754
+ headers: { "Content-Type": "application/json" },
755
+ body: JSON.stringify({
756
+ session_id: currentSessionId,
757
+ cluster_name: currentClusterName,
758
+ image_to_keep: keep,
759
+ }),
760
+ });
761
+ const data = await res.json();
762
+ const old = currentGroups[currentClusterName];
763
+ const deleted = old.filter((p) => p !== data.image_kept);
764
+ syncUniverseMap(deleted);
765
+ currentGroups[currentClusterName] = [data.image_kept];
766
+ loadCluster(currentClusterName);
767
+ renderClusterList();
768
+ } catch (e) {
769
+ alert(e);
770
+ }
771
+ };
772
+
773
+ document.getElementById("delete-group-btn").onclick = async () => {
774
+ if (!confirm(`Delete group ${currentClusterName}?`)) return;
775
+ const res = await fetch(`${API_URL}/delete-group`, {
776
+ method: "POST",
777
+ headers: { "Content-Type": "application/json" },
778
+ body: JSON.stringify({
779
+ session_id: currentSessionId,
780
+ cluster_name: currentClusterName,
781
+ }),
782
+ });
783
+ if (res.ok) {
784
+ delete currentGroups[currentClusterName];
785
+ currentClusterName = null;
786
+ renderClusterList();
787
+ document.getElementById("thumbnail-gallery").innerHTML = "";
788
+ }
789
+ };
790
+
791
+ document.getElementById("move-btn").onclick = () => {
792
+ const paths = Array.from(
793
+ document.querySelectorAll(".thumbnail-card.selected")
794
+ ).map((c) => c.dataset.path);
795
+ if (!paths.length) return alert("Select images to move.");
796
+ const sel = document.getElementById("move-cluster-select");
797
+ sel.innerHTML = '<option value="__NEW__">-- New Cluster --</option>';
798
+ Object.keys(currentGroups).forEach((g) => {
799
+ if (g !== currentClusterName)
800
+ sel.innerHTML += `<option value="${g}">${g}</option>`;
801
+ });
802
+ document.getElementById("move-modal").classList.remove("hidden");
803
+ };
804
+
805
+ document.getElementById("move-cancel-btn").onclick = () =>
806
+ document.getElementById("move-modal").classList.add("hidden");
807
+
808
+ document.getElementById("move-cluster-select").onchange = (e) =>
809
+ document
810
+ .getElementById("move-new-cluster-input-group")
811
+ .classList.toggle("hidden", e.target.value !== "__NEW__");
812
+
813
+ document.getElementById("move-confirm-btn").onclick = async () => {
814
+ const paths = Array.from(
815
+ document.querySelectorAll(".thumbnail-card.selected")
816
+ ).map((c) => c.dataset.path);
817
+ let dest = document.getElementById("move-cluster-select").value;
818
+ if (dest === "__NEW__")
819
+ dest = document.getElementById("move-new-cluster-name").value.trim();
820
+ if (!dest) return alert("Invalid name");
821
+
822
+ document.getElementById("move-modal").classList.add("hidden");
823
+ try {
824
+ const res = await fetch(`${API_URL}/move-images`, {
825
+ method: "POST",
826
+ headers: { "Content-Type": "application/json" },
827
+ body: JSON.stringify({
828
+ session_id: currentSessionId,
829
+ image_paths: paths,
830
+ destination_cluster: dest,
831
+ }),
832
+ });
833
+ const data = await res.json();
834
+ currentGroups[currentClusterName] = currentGroups[
835
+ currentClusterName
836
+ ].filter((p) => !paths.includes(p));
837
+ if (!currentGroups[dest]) currentGroups[dest] = [];
838
+ currentGroups[dest].push(
839
+ ...data.moved.map((p) =>
840
+ p.includes("/") ? p : `${dest}/${p.split("/").pop()}`
841
+ )
842
+ );
843
+ loadCluster(currentClusterName);
844
+ renderClusterList();
845
+
846
+ universeState.data.forEach((p) => {
847
+ const fileName = p.path.split("/").pop();
848
+ if (paths.some((movedPath) => movedPath.endsWith(fileName))) {
849
+ p.cluster = dest;
850
+ }
851
+ });
852
+ renderUniverseMap(universeState.data);
853
+ } catch (e) {
854
+ alert(e);
855
+ }
856
+ };
857
+
858
+ function renderCharts(unique, dupes, groups) {
859
+ const ctx1 = document.getElementById("summary-chart").getContext("2d");
860
+ if (window.c3) window.c3.destroy();
861
+ window.c3 = new Chart(ctx1, {
862
+ type: "doughnut",
863
+ data: {
864
+ labels: ["Unique", "Duplicate"],
865
+ datasets: [
866
+ {
867
+ data: [unique, dupes],
868
+ backgroundColor: ["#3b82f6", "#ef4444"],
869
+ borderWidth: 0,
870
+ },
871
+ ],
872
+ },
873
+ options: {
874
+ maintainAspectRatio: false,
875
+ plugins: {
876
+ legend: { position: "bottom", labels: { color: "#ccc" } },
877
+ },
878
+ },
879
+ });
880
+
881
+ const bins = [0, 0, 0];
882
+ Object.values(groups).forEach((g) => {
883
+ if (g.length <= 2) bins[0]++;
884
+ else if (g.length <= 5) bins[1]++;
885
+ else bins[2]++;
886
+ });
887
+ const ctx2 = document
888
+ .getElementById("distribution-chart")
889
+ .getContext("2d");
890
+ if (window.c4) window.c4.destroy();
891
+ window.c4 = new Chart(ctx2, {
892
+ type: "bar",
893
+ data: {
894
+ labels: ["Small", "Medium", "Large"],
895
+ datasets: [
896
+ { label: "Clusters", data: bins, backgroundColor: "#8b5cf6" },
897
+ ],
898
+ },
899
+ options: {
900
+ maintainAspectRatio: false,
901
+ plugins: { legend: { display: false } },
902
+ scales: {
903
+ y: { grid: { color: "#333" } },
904
+ x: { grid: { display: false } },
905
+ },
906
+ },
907
+ });
908
+ }
909
+
910
+ document.getElementById("select-all-btn").onclick = () =>
911
+ document.querySelectorAll(".thumbnail-card").forEach((c) => {
912
+ c.classList.add("selected");
913
+ c.querySelector("input").checked = true;
914
+ });
915
+
916
+ document.getElementById("deselect-all-btn").onclick = () =>
917
+ document.querySelectorAll(".thumbnail-card").forEach((c) => {
918
+ c.classList.remove("selected");
919
+ c.querySelector("input").checked = false;
920
+ });
921
+
922
+ document.getElementById("keep-best-btn").onclick = () => {
923
+ const best = qualityScores[currentClusterName]?.images.find(
924
+ (i) => i.is_best
925
+ );
926
+ if (best) {
927
+ document
928
+ .querySelectorAll(".thumbnail-card")
929
+ .forEach((c) => c.classList.remove("selected"));
930
+ document
931
+ .querySelector(`[data-path="${best.path}"]`)
932
+ ?.classList.add("selected");
933
+ document.querySelector(
934
+ `[data-path="${best.path}"] input`
935
+ ).checked = true;
936
+ }
937
+ };
app/styles.css ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* --- STYLES --- */
2
+ body {
3
+ background-color: #121212;
4
+ color: #e0e0e0;
5
+ font-family: "Outfit", sans-serif;
6
+ overflow: hidden;
7
+ }
8
+
9
+ ::-webkit-scrollbar {
10
+ width: 6px;
11
+ height: 6px;
12
+ }
13
+ ::-webkit-scrollbar-track {
14
+ background: #1a1a1a;
15
+ }
16
+ ::-webkit-scrollbar-thumb {
17
+ background: #444;
18
+ border-radius: 4px;
19
+ }
20
+ ::-webkit-scrollbar-thumb:hover {
21
+ background: #666;
22
+ }
23
+
24
+ .tab-button {
25
+ transition: all 0.2s ease;
26
+ border-bottom: 2px solid transparent;
27
+ position: relative;
28
+ }
29
+ .tab-button.active {
30
+ color: #8b5cf6;
31
+ border-bottom-color: #8b5cf6;
32
+ background: rgba(139, 92, 246, 0.05);
33
+ }
34
+ .tab-button:hover {
35
+ color: #fff;
36
+ }
37
+
38
+ .cluster-button {
39
+ transition: all 0.2s ease;
40
+ }
41
+ .cluster-button.active {
42
+ background-color: #8b5cf6;
43
+ color: white;
44
+ border-left: 3px solid #c4b5fd;
45
+ }
46
+
47
+ .thumbnail-card {
48
+ background-color: #1e1e1e;
49
+ border: 1px solid #333;
50
+ transition: all 0.2s ease;
51
+ position: relative;
52
+ overflow: hidden;
53
+ }
54
+ .thumbnail-card:hover {
55
+ border-color: #8b5cf6;
56
+ transform: translateY(-2px);
57
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
58
+ }
59
+ .thumbnail-card.selected {
60
+ border-color: #8b5cf6;
61
+ background-color: rgba(139, 92, 246, 0.1);
62
+ ring: 1px solid #8b5cf6;
63
+ }
64
+ .thumbnail-card.best-quality {
65
+ border: 2px solid #10b981;
66
+ box-shadow: 0 0 15px rgba(16, 185, 129, 0.15);
67
+ }
68
+
69
+ .quality-badge {
70
+ position: absolute;
71
+ top: 4px;
72
+ right: 4px;
73
+ padding: 2px 6px;
74
+ border-radius: 4px;
75
+ font-size: 10px;
76
+ font-weight: 700;
77
+ color: white;
78
+ z-index: 10;
79
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
80
+ font-family: "JetBrains Mono", monospace;
81
+ cursor: help;
82
+ }
83
+ .best-badge {
84
+ position: absolute;
85
+ top: 4px;
86
+ left: 4px;
87
+ background: linear-gradient(135deg, #10b981, #059669);
88
+ padding: 2px 6px;
89
+ border-radius: 4px;
90
+ font-size: 9px;
91
+ font-weight: 800;
92
+ color: white;
93
+ z-index: 10;
94
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
95
+ }
96
+
97
+ .quality-details {
98
+ position: absolute;
99
+ top: 20px;
100
+ right: 0;
101
+ background: #1f2937;
102
+ border: 1px solid #374151;
103
+ border-radius: 6px;
104
+ padding: 8px;
105
+ font-size: 11px;
106
+ white-space: nowrap;
107
+ z-index: 100;
108
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
109
+ display: none;
110
+ min-width: 120px;
111
+ pointer-events: none;
112
+ }
113
+ .quality-badge:hover .quality-details {
114
+ display: block;
115
+ }
116
+ .quality-metric {
117
+ display: flex;
118
+ justify-content: space-between;
119
+ gap: 12px;
120
+ margin: 2px 0;
121
+ }
122
+ .quality-metric-label {
123
+ color: #9ca3af;
124
+ }
125
+ .quality-metric-value {
126
+ color: #e5e7eb;
127
+ font-weight: 600;
128
+ }
129
+
130
+ /* --- CINEMATIC UNIVERSE MAP --- */
131
+ #tab-universe {
132
+ position: absolute;
133
+ inset: 0;
134
+ background: radial-gradient(ellipse at center, #0f0920 0%, #000000 100%);
135
+ overflow: hidden;
136
+ }
137
+
138
+ #plotly-div {
139
+ position: absolute;
140
+ inset: 0;
141
+ z-index: 1;
142
+ background: transparent;
143
+ transition: opacity 0.8s ease;
144
+ }
145
+
146
+ .star-layer {
147
+ position: absolute;
148
+ inset: 0;
149
+ pointer-events: none;
150
+ z-index: 0;
151
+ opacity: 0.6;
152
+ }
153
+
154
+ .stars-small {
155
+ width: 1px;
156
+ height: 1px;
157
+ background: transparent;
158
+ box-shadow: 1744px 122px 2px rgba(255, 255, 255, 0.8),
159
+ 134px 1321px 1px rgba(255, 255, 255, 0.6),
160
+ 92px 859px 1px rgba(139, 92, 246, 0.4), 800px 600px 1px #fff,
161
+ 1200px 400px 1px rgba(255, 255, 255, 0.7),
162
+ 300px 1100px 1px rgba(167, 139, 250, 0.5);
163
+ animation: animStar 150s linear infinite, twinkle 3s ease-in-out infinite;
164
+ }
165
+
166
+ .stars-medium {
167
+ width: 2px;
168
+ height: 2px;
169
+ background: transparent;
170
+ box-shadow: 122px 231px 3px rgba(139, 92, 246, 0.9),
171
+ 421px 521px 2px rgba(255, 255, 255, 0.8),
172
+ 900px 300px 2px rgba(167, 139, 250, 0.7),
173
+ 600px 800px 2px rgba(255, 255, 255, 0.6);
174
+ animation: animStar 100s linear infinite,
175
+ glow 2s ease-in-out infinite alternate;
176
+ }
177
+
178
+ @keyframes animStar {
179
+ from {
180
+ transform: translateY(0px);
181
+ }
182
+ to {
183
+ transform: translateY(-2000px);
184
+ }
185
+ }
186
+
187
+ @keyframes twinkle {
188
+ 0%,
189
+ 100% {
190
+ opacity: 0.6;
191
+ }
192
+ 50% {
193
+ opacity: 1;
194
+ }
195
+ }
196
+
197
+ @keyframes glow {
198
+ 0% {
199
+ filter: brightness(1);
200
+ }
201
+ 100% {
202
+ filter: brightness(1.5) drop-shadow(0 0 4px rgba(139, 92, 246, 0.6));
203
+ }
204
+ }
205
+
206
+ #tab-universe::before {
207
+ content: "";
208
+ position: absolute;
209
+ inset: 0;
210
+ background: radial-gradient(
211
+ ellipse at 20% 30%,
212
+ rgba(139, 92, 246, 0.15) 0%,
213
+ transparent 50%
214
+ ),
215
+ radial-gradient(
216
+ ellipse at 80% 70%,
217
+ rgba(59, 130, 246, 0.12) 0%,
218
+ transparent 50%
219
+ ),
220
+ radial-gradient(
221
+ ellipse at 50% 50%,
222
+ rgba(167, 139, 250, 0.08) 0%,
223
+ transparent 60%
224
+ );
225
+ animation: nebulaPulse 8s ease-in-out infinite alternate;
226
+ z-index: 0;
227
+ pointer-events: none;
228
+ }
229
+
230
+ @keyframes nebulaPulse {
231
+ 0% {
232
+ opacity: 0.3;
233
+ transform: scale(1);
234
+ }
235
+ 100% {
236
+ opacity: 0.6;
237
+ transform: scale(1.05);
238
+ }
239
+ }
240
+
241
+ @keyframes floatIn {
242
+ from {
243
+ opacity: 0;
244
+ transform: translateX(-50%) translateY(-20px);
245
+ }
246
+ to {
247
+ opacity: 1;
248
+ transform: translateX(-50%) translateY(0);
249
+ }
250
+ }
251
+
252
+ .stat-box {
253
+ background: #1e1e1e;
254
+ border: 1px solid #333;
255
+ border-radius: 12px;
256
+ padding: 20px;
257
+ position: relative;
258
+ overflow: hidden;
259
+ }
260
+ .stat-box::after {
261
+ content: "";
262
+ position: absolute;
263
+ top: 0;
264
+ left: 0;
265
+ width: 4px;
266
+ height: 100%;
267
+ background: #333;
268
+ }
269
+ .stat-box.purple::after {
270
+ background: #8b5cf6;
271
+ }
272
+ .stat-box.green::after {
273
+ background: #10b981;
274
+ }
275
+ .stat-box.red::after {
276
+ background: #ef4444;
277
+ }
278
+ .stat-box.blue::after {
279
+ background: #3b82f6;
280
+ }
281
+
282
+ .pipeline-step {
283
+ position: relative;
284
+ padding-left: 20px;
285
+ border-left: 2px solid #333;
286
+ padding-bottom: 20px;
287
+ }
288
+ .pipeline-step:last-child {
289
+ border-left: 2px solid transparent;
290
+ }
291
+ .pipeline-step::before {
292
+ content: "";
293
+ position: absolute;
294
+ left: -5px;
295
+ top: 0;
296
+ width: 8px;
297
+ height: 8px;
298
+ border-radius: 50%;
299
+ background: #555;
300
+ }
301
+ .pipeline-step.done::before {
302
+ background: #10b981;
303
+ box-shadow: 0 0 8px #10b981;
304
+ }
305
+ .pipeline-step.done {
306
+ border-left-color: #10b981;
307
+ }
308
+
309
+ .glass-panel-ui {
310
+ background: rgba(20, 20, 25, 0.8);
311
+ backdrop-filter: blur(16px);
312
+ border: 1px solid rgba(255, 255, 255, 0.1);
313
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
314
+ pointer-events: auto;
315
+ z-index: 50;
316
+ }
317
+
318
+ .search-container {
319
+ position: absolute;
320
+ top: 24px;
321
+ left: 50%;
322
+ transform: translateX(-50%);
323
+ width: 400px;
324
+ max-width: 90%;
325
+ border-radius: 99px;
326
+ padding: 4px;
327
+ display: flex;
328
+ transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
329
+ animation: floatIn 0.8s ease-out;
330
+ }
331
+
332
+ .search-container:focus-within {
333
+ background: rgba(20, 20, 25, 0.95);
334
+ border-color: #8b5cf6;
335
+ box-shadow: 0 0 30px rgba(139, 92, 246, 0.4), 0 0 60px rgba(139, 92, 246, 0.2),
336
+ inset 0 0 20px rgba(139, 92, 246, 0.1);
337
+ transform: translateX(-50%) scale(1.02);
338
+ }
339
+
340
+ .search-input {
341
+ background: transparent;
342
+ border: none;
343
+ color: white;
344
+ padding: 8px 16px;
345
+ width: 100%;
346
+ outline: none;
347
+ font-size: 14px;
348
+ }
349
+
350
+ .bottom-controls {
351
+ position: absolute;
352
+ bottom: 30px;
353
+ left: 50%;
354
+ transform: translateX(-50%);
355
+ border-radius: 16px;
356
+ padding: 8px 16px;
357
+ display: flex;
358
+ gap: 16px;
359
+ align-items: center;
360
+ animation: floatIn 1s ease-out 0.3s backwards;
361
+ transition: all 0.3s ease;
362
+ }
363
+
364
+ .bottom-controls:hover {
365
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.8), 0 0 20px rgba(139, 92, 246, 0.2);
366
+ transform: translateX(-50%) translateY(-2px);
367
+ }
368
+
369
+ .toggle-switch {
370
+ appearance: none;
371
+ width: 36px;
372
+ height: 20px;
373
+ background: #334155;
374
+ border-radius: 20px;
375
+ position: relative;
376
+ transition: 0.3s;
377
+ cursor: pointer;
378
+ }
379
+ .toggle-switch::after {
380
+ content: "";
381
+ position: absolute;
382
+ top: 2px;
383
+ left: 2px;
384
+ width: 16px;
385
+ height: 16px;
386
+ background: #fff;
387
+ border-radius: 50%;
388
+ transition: 0.3s;
389
+ }
390
+ .toggle-switch:checked {
391
+ background: #8b5cf6;
392
+ }
393
+ .toggle-switch:checked::after {
394
+ transform: translateX(16px);
395
+ }