w4nn4b3M4ST3R commited on
Commit
13a5bb2
·
1 Parent(s): 9640c8d
Files changed (2) hide show
  1. app/static/Universe3D.js +105 -80
  2. app/static/script.js +259 -720
app/static/Universe3D.js CHANGED
@@ -7,28 +7,28 @@ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js"
7
  let scene, camera, renderer, composer, controls;
8
  let raycaster, pointer;
9
  let clusterParticles = [];
10
- let isAnimating = true;
11
 
12
  // Cấu hình màu sắc Sci-fi
13
  const PALETTE = {
14
  noise: 0x333333,
15
- cluster: [0x8b5cf6, 0x3b82f6, 0x10b981, 0xf59e0b, 0xec4899, 0x06b6d4], // Violet, Blue, Green, Amber, Pink, Cyan
16
- highlight: 0xffffff,
17
  };
18
 
19
  export function initUniverse(containerId, data, onNodeClick) {
20
  const container = document.getElementById(containerId);
21
- container.innerHTML = ""; // Xóa nội dung cũ (Plotly div)
 
22
 
23
  // 1. Scene & Camera Setup
24
  scene = new THREE.Scene();
25
- scene.fog = new THREE.FogExp2(0x05010a, 0.002); // Sương mù vũ trụ tối
26
 
27
  camera = new THREE.PerspectiveCamera(
28
  60,
29
  container.clientWidth / container.clientHeight,
30
  0.1,
31
- 2000
32
  );
33
  camera.position.set(0, 40, 60);
34
 
@@ -38,14 +38,14 @@ export function initUniverse(containerId, data, onNodeClick) {
38
  renderer.toneMapping = THREE.ReinhardToneMapping;
39
  container.appendChild(renderer.domElement);
40
 
41
- // 2. Controls (Orbit)
42
  controls = new OrbitControls(camera, renderer.domElement);
43
  controls.enableDamping = true;
44
  controls.dampingFactor = 0.05;
45
  controls.autoRotate = true;
46
  controls.autoRotateSpeed = 0.5;
47
 
48
- // 3. Post-Processing (BLOOM EFFECT - Quan trọng nhất cho giao diện Sci-fi)
49
  const renderScene = new RenderPass(scene, camera);
50
  const bloomPass = new UnrealBloomPass(
51
  new THREE.Vector2(window.innerWidth, window.innerHeight),
@@ -54,76 +54,97 @@ export function initUniverse(containerId, data, onNodeClick) {
54
  0.85
55
  );
56
  bloomPass.threshold = 0.1;
57
- bloomPass.strength = 1.2; // Độ mạnh của luồng sáng
58
  bloomPass.radius = 0.5;
59
 
60
  composer = new EffectComposer(renderer);
61
  composer.addPass(renderScene);
62
  composer.addPass(bloomPass);
63
 
64
- // 4. Lighting
65
- const ambientLight = new THREE.AmbientLight(0x404040);
66
- scene.add(ambientLight);
67
-
68
- // Ánh sáng tâm điểm
69
  const pointLight = new THREE.PointLight(0xffffff, 1, 100);
70
  scene.add(pointLight);
71
-
72
- // 5. Starfield Background (Hạt bụi không gian)
73
  createStarfield();
74
 
75
- // 6. Generate Clusters (Biến dữ liệu thành các thiên hà nhỏ)
76
  generateGalaxy(data);
77
 
78
- // 7. Raycaster (Để click/hover)
79
  raycaster = new THREE.Raycaster();
80
  pointer = new THREE.Vector2();
81
 
82
- // Event Listeners
83
  window.addEventListener("resize", onWindowResize);
84
  container.addEventListener("mousemove", onPointerMove);
85
  container.addEventListener("click", (event) =>
86
  onMouseClick(event, onNodeClick)
87
  );
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  animate();
90
  }
91
 
92
  function createStarfield() {
93
  const geometry = new THREE.BufferGeometry();
94
- const count = 2000;
95
  const positions = new Float32Array(count * 3);
96
-
97
- for (let i = 0; i < count * 3; i++) {
98
- positions[i] = (Math.random() - 0.5) * 400; // Rải rác rộng
99
- }
100
 
101
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
102
-
103
- // Material tạo đốm sáng nhỏ
104
  const material = new THREE.PointsMaterial({
105
  size: 0.5,
106
- color: 0x8b5cf6, // Tím nhạt
107
  transparent: true,
108
  opacity: 0.4,
109
- sizeAttenuation: true,
110
  });
111
-
112
- const starField = new THREE.Points(geometry, material);
113
- scene.add(starField);
114
  }
115
 
116
  function generateGalaxy(data) {
117
- // Group dữ liệu theo cluster
118
  const clusters = {};
119
  data.forEach((p) => {
120
  if (!clusters[p.cluster]) clusters[p.cluster] = [];
121
  clusters[p.cluster].push(p);
122
  });
123
 
124
- // Tạo vật liệu chung cho các node (Sprite phát sáng)
125
- const textureLoader = new THREE.TextureLoader();
126
- // Tạo texture tròn phát sáng bằng code (không cần file ảnh)
127
  const canvas = document.createElement("canvas");
128
  canvas.width = 32;
129
  canvas.height = 32;
@@ -142,25 +163,23 @@ function generateGalaxy(data) {
142
  const group = clusters[key];
143
  const isNoise = key === "Noise/Unique";
144
 
145
- // Chọn màu
146
  let colorHex = isNoise
147
  ? PALETTE.noise
148
  : PALETTE.cluster[index % PALETTE.cluster.length];
 
 
149
  const material = new THREE.SpriteMaterial({
150
  map: spriteTexture,
151
  color: colorHex,
152
  transparent: true,
153
  opacity: 0.9,
154
- blending: THREE.AdditiveBlending, // Chế độ hòa trộn ánh sáng
155
  });
156
 
157
- group.forEach((item, i) => {
158
- const sprite = new THREE.Sprite(material);
159
 
160
- // LOGIC SẮP XẾP MỚI:
161
- // Thay vì dùng toạ độ x,y,z gốc (Plotly), ta có thể scale chúng rộng ra
162
- // hoặc sắp xếp lại thành các "Hệ mặt trời" nếu muốn.
163
- // Ở đây giữ nguyên toạ độ AI trả về nhưng nhân hệ số scale để thoáng hơn
164
  const scaleFactor = 1.5;
165
  sprite.position.set(
166
  item.x * scaleFactor,
@@ -168,101 +187,109 @@ function generateGalaxy(data) {
168
  item.z * scaleFactor
169
  );
170
 
171
- // Scale kích thước: Ảnh Best to hơn, Noise nhỏ hơn
172
- const size = item.quality && item.is_best ? 3.5 : isNoise ? 0.8 : 2.0;
 
 
 
173
  sprite.scale.set(size, size, 1);
174
 
175
- // Gán dữ liệu vào object để truy xuất khi click
176
  sprite.userData = {
177
  id: item.path,
178
  cluster: item.cluster,
179
  filename: item.filename,
180
  quality: item.quality,
 
 
181
  };
182
 
183
  scene.add(sprite);
184
  clusterParticles.push(sprite);
185
  });
186
 
187
- // Vẽ đường nối (Constellation lines) cho các node trong cùng 1 cluster
188
  if (!isNoise && group.length > 1) {
189
- const lineGeo = new THREE.BufferGeometry();
190
- const points = group.map(
191
- (p) => new THREE.Vector3(p.x * 1.5, p.y * 1.5, p.z * 1.5)
192
  );
193
- lineGeo.setFromPoints(points);
194
  const lineMat = new THREE.LineBasicMaterial({
195
  color: colorHex,
196
  transparent: true,
197
  opacity: 0.15,
198
  });
199
- const line = new THREE.Line(lineGeo, lineMat);
200
- scene.add(line);
201
  }
202
  });
203
  }
204
 
205
- // Xử lý Highlight khi Hover
206
  function onPointerMove(event) {
207
  const rect = renderer.domElement.getBoundingClientRect();
208
  pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
209
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
210
 
211
- // Hover logic đơn giản:
212
  raycaster.setFromCamera(pointer, camera);
213
  const intersects = raycaster.intersectObjects(clusterParticles, false);
214
 
215
  if (intersects.length > 0) {
216
  document.body.style.cursor = "pointer";
217
- // Có thể gọi hàm show tooltip ở script chính tại đây
218
- const data = intersects[0].object.userData;
219
- window.dispatchEvent(new CustomEvent("universe-hover", { detail: data }));
 
 
220
  } else {
221
  document.body.style.cursor = "default";
222
  window.dispatchEvent(new CustomEvent("universe-unhover"));
223
  }
224
  }
225
 
226
- // Xử lý Click
227
  function onMouseClick(event, callback) {
228
  raycaster.setFromCamera(pointer, camera);
229
  const intersects = raycaster.intersectObjects(clusterParticles, false);
230
 
231
  if (intersects.length > 0) {
232
  const target = intersects[0].object;
233
- const data = target.userData;
234
 
235
- // Hiệu ứng Flash khi click
236
  target.material.color.setHex(0xffffff);
237
  setTimeout(() => {
238
- // Trả về màu cũ (cần lưu màu gốc nếu muốn hoàn hảo, ở đây demo set lại màu)
239
- // Trong thực tế nên lưu originalColor vào userData
240
- }, 300);
241
 
242
- // Bay camera tới mục tiêu (Cinematic Fly-to)
243
  flyTo(target.position, () => {
244
- if (callback) callback(data);
245
  });
246
  }
247
  }
248
 
249
  function flyTo(targetPos, onComplete) {
250
- // Tạm dừng auto rotate
251
  controls.autoRotate = false;
252
-
253
- const offset = new THREE.Vector3(10, 10, 10); // Khoảng cách zoom
254
- const endPos = new THREE.Vector3().copy(targetPos).add(offset);
255
-
256
- // Dùng GSAP hoặc tween đơn giản. Ở đây code tay logic lerp đơn giản trong animate loop
257
- // Để đơn giản cho demo, ta set target của controls:
258
- controls.target.copy(targetPos);
259
- // (Muốn mượt hơn cần thư viện TWEEN.js, nhưng controls.update sẽ lo phần damping)
260
-
261
- if (onComplete) onComplete();
 
 
 
 
 
 
 
 
 
262
  }
263
 
264
  function onWindowResize() {
265
  const container = renderer.domElement.parentElement;
 
266
  camera.aspect = container.clientWidth / container.clientHeight;
267
  camera.updateProjectionMatrix();
268
  renderer.setSize(container.clientWidth, container.clientHeight);
@@ -271,8 +298,6 @@ function onWindowResize() {
271
 
272
  function animate() {
273
  requestAnimationFrame(animate);
274
- controls.update(); // Cần cho Damping và AutoRotate
275
-
276
- // Render qua Composer (để có Bloom) thay vì renderer thường
277
  composer.render();
278
  }
 
7
  let scene, camera, renderer, composer, controls;
8
  let raycaster, pointer;
9
  let clusterParticles = [];
 
10
 
11
  // Cấu hình màu sắc Sci-fi
12
  const PALETTE = {
13
  noise: 0x333333,
14
+ cluster: [0x8b5cf6, 0x3b82f6, 0x10b981, 0xf59e0b, 0xec4899, 0x06b6d4],
15
+ highlight: 0xffaa00, // Màu vàng cam khi search trúng
16
  };
17
 
18
  export function initUniverse(containerId, data, onNodeClick) {
19
  const container = document.getElementById(containerId);
20
+ container.innerHTML = "";
21
+ clusterParticles = []; // Reset mảng chứa hạt
22
 
23
  // 1. Scene & Camera Setup
24
  scene = new THREE.Scene();
25
+ scene.fog = new THREE.FogExp2(0x05010a, 0.002);
26
 
27
  camera = new THREE.PerspectiveCamera(
28
  60,
29
  container.clientWidth / container.clientHeight,
30
  0.1,
31
+ 3000
32
  );
33
  camera.position.set(0, 40, 60);
34
 
 
38
  renderer.toneMapping = THREE.ReinhardToneMapping;
39
  container.appendChild(renderer.domElement);
40
 
41
+ // 2. Controls
42
  controls = new OrbitControls(camera, renderer.domElement);
43
  controls.enableDamping = true;
44
  controls.dampingFactor = 0.05;
45
  controls.autoRotate = true;
46
  controls.autoRotateSpeed = 0.5;
47
 
48
+ // 3. Bloom Effect
49
  const renderScene = new RenderPass(scene, camera);
50
  const bloomPass = new UnrealBloomPass(
51
  new THREE.Vector2(window.innerWidth, window.innerHeight),
 
54
  0.85
55
  );
56
  bloomPass.threshold = 0.1;
57
+ bloomPass.strength = 1.2;
58
  bloomPass.radius = 0.5;
59
 
60
  composer = new EffectComposer(renderer);
61
  composer.addPass(renderScene);
62
  composer.addPass(bloomPass);
63
 
64
+ // 4. Môi trường
65
+ scene.add(new THREE.AmbientLight(0x404040));
 
 
 
66
  const pointLight = new THREE.PointLight(0xffffff, 1, 100);
67
  scene.add(pointLight);
 
 
68
  createStarfield();
69
 
70
+ // 5. Tạo các điểm dữ liệu
71
  generateGalaxy(data);
72
 
73
+ // 6. Raycaster
74
  raycaster = new THREE.Raycaster();
75
  pointer = new THREE.Vector2();
76
 
77
+ // Events
78
  window.addEventListener("resize", onWindowResize);
79
  container.addEventListener("mousemove", onPointerMove);
80
  container.addEventListener("click", (event) =>
81
  onMouseClick(event, onNodeClick)
82
  );
83
 
84
+ // --- SEARCH EVENT ---
85
+ window.addEventListener("universe-search", (e) => {
86
+ const { filenames } = e.detail;
87
+ controls.autoRotate = false; // Dừng quay để tập trung
88
+
89
+ clusterParticles.forEach((sprite) => {
90
+ const name = sprite.userData.filename;
91
+ const path = sprite.userData.id;
92
+
93
+ const isMatch = filenames.some((f) => path.includes(f) || name === f);
94
+
95
+ if (isMatch) {
96
+ // Highlight: Màu Vàng, Scale To, Rõ
97
+ sprite.material.color.setHex(PALETTE.highlight);
98
+ sprite.scale.setScalar(sprite.userData.originalScale * 2.5);
99
+ sprite.material.opacity = 1;
100
+ } else {
101
+ // Dim: Màu tối, Scale nhỏ, Mờ
102
+ sprite.material.color.setHex(0x111111);
103
+ sprite.scale.setScalar(sprite.userData.originalScale * 0.5);
104
+ sprite.material.opacity = 0.1;
105
+ }
106
+ });
107
+ });
108
+
109
+ // --- RESET EVENT ---
110
+ window.addEventListener("universe-search-reset", () => {
111
+ controls.autoRotate = true; // Quay lại
112
+ clusterParticles.forEach((sprite) => {
113
+ // Trả về màu và kích thước gốc đã lưu
114
+ sprite.material.color.setHex(sprite.userData.originalColor);
115
+ sprite.scale.setScalar(sprite.userData.originalScale);
116
+ sprite.material.opacity = 0.9;
117
+ });
118
+ });
119
+
120
  animate();
121
  }
122
 
123
  function createStarfield() {
124
  const geometry = new THREE.BufferGeometry();
125
+ const count = 1500;
126
  const positions = new Float32Array(count * 3);
127
+ for (let i = 0; i < count * 3; i++)
128
+ positions[i] = (Math.random() - 0.5) * 500;
 
 
129
 
130
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
 
 
131
  const material = new THREE.PointsMaterial({
132
  size: 0.5,
133
+ color: 0x8b5cf6,
134
  transparent: true,
135
  opacity: 0.4,
 
136
  });
137
+ scene.add(new THREE.Points(geometry, material));
 
 
138
  }
139
 
140
  function generateGalaxy(data) {
 
141
  const clusters = {};
142
  data.forEach((p) => {
143
  if (!clusters[p.cluster]) clusters[p.cluster] = [];
144
  clusters[p.cluster].push(p);
145
  });
146
 
147
+ // Tạo Texture tròn bằng Canvas
 
 
148
  const canvas = document.createElement("canvas");
149
  canvas.width = 32;
150
  canvas.height = 32;
 
163
  const group = clusters[key];
164
  const isNoise = key === "Noise/Unique";
165
 
166
+ // Chọn màu cho Cluster
167
  let colorHex = isNoise
168
  ? PALETTE.noise
169
  : PALETTE.cluster[index % PALETTE.cluster.length];
170
+
171
+ // Tạo Material gốc cho cluster này
172
  const material = new THREE.SpriteMaterial({
173
  map: spriteTexture,
174
  color: colorHex,
175
  transparent: true,
176
  opacity: 0.9,
177
+ blending: THREE.AdditiveBlending,
178
  });
179
 
180
+ group.forEach((item) => {
181
+ const sprite = new THREE.Sprite(material.clone()); // Clone để có thể đổi màu riêng từng hạt
182
 
 
 
 
 
183
  const scaleFactor = 1.5;
184
  sprite.position.set(
185
  item.x * scaleFactor,
 
187
  item.z * scaleFactor
188
  );
189
 
190
+ // Tính kích thước
191
+ let size = 2.0;
192
+ if (item.quality && item.is_best) size = 3.5;
193
+ if (isNoise) size = 0.8;
194
+
195
  sprite.scale.set(size, size, 1);
196
 
197
+ // [QUAN TRỌNG] Lưu thông tin gốc để Restore sau khi Search
198
  sprite.userData = {
199
  id: item.path,
200
  cluster: item.cluster,
201
  filename: item.filename,
202
  quality: item.quality,
203
+ originalColor: colorHex, // <--- Lưu màu gốc
204
+ originalScale: size, // <--- Lưu size gốc
205
  };
206
 
207
  scene.add(sprite);
208
  clusterParticles.push(sprite);
209
  });
210
 
211
+ // Vẽ đường nối
212
  if (!isNoise && group.length > 1) {
213
+ const lineGeo = new THREE.BufferGeometry().setFromPoints(
214
+ group.map((p) => new THREE.Vector3(p.x * 1.5, p.y * 1.5, p.z * 1.5))
 
215
  );
 
216
  const lineMat = new THREE.LineBasicMaterial({
217
  color: colorHex,
218
  transparent: true,
219
  opacity: 0.15,
220
  });
221
+ scene.add(new THREE.Line(lineGeo, lineMat));
 
222
  }
223
  });
224
  }
225
 
 
226
  function onPointerMove(event) {
227
  const rect = renderer.domElement.getBoundingClientRect();
228
  pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
229
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
230
 
 
231
  raycaster.setFromCamera(pointer, camera);
232
  const intersects = raycaster.intersectObjects(clusterParticles, false);
233
 
234
  if (intersects.length > 0) {
235
  document.body.style.cursor = "pointer";
236
+ window.dispatchEvent(
237
+ new CustomEvent("universe-hover", {
238
+ detail: intersects[0].object.userData,
239
+ })
240
+ );
241
  } else {
242
  document.body.style.cursor = "default";
243
  window.dispatchEvent(new CustomEvent("universe-unhover"));
244
  }
245
  }
246
 
 
247
  function onMouseClick(event, callback) {
248
  raycaster.setFromCamera(pointer, camera);
249
  const intersects = raycaster.intersectObjects(clusterParticles, false);
250
 
251
  if (intersects.length > 0) {
252
  const target = intersects[0].object;
 
253
 
254
+ // Hiệu ứng Flash
255
  target.material.color.setHex(0xffffff);
256
  setTimeout(() => {
257
+ // Trả về màu cũ dựa trên userData
258
+ target.material.color.setHex(target.userData.originalColor);
259
+ }, 200);
260
 
 
261
  flyTo(target.position, () => {
262
+ if (callback) callback(target.userData);
263
  });
264
  }
265
  }
266
 
267
  function flyTo(targetPos, onComplete) {
 
268
  controls.autoRotate = false;
269
+ const startTarget = controls.target.clone();
270
+ const duration = 1000;
271
+ const startTime = performance.now();
272
+
273
+ function animateFly(time) {
274
+ const elapsed = time - startTime;
275
+ const progress = Math.min(elapsed / duration, 1);
276
+ const ease = 1 - Math.pow(1 - progress, 3); // Cubic ease out
277
+
278
+ // Interpolate controls target
279
+ controls.target.lerpVectors(startTarget, targetPos, 0.1);
280
+
281
+ if (progress < 1) {
282
+ requestAnimationFrame(animateFly);
283
+ } else {
284
+ if (onComplete) onComplete();
285
+ }
286
+ }
287
+ requestAnimationFrame(animateFly);
288
  }
289
 
290
  function onWindowResize() {
291
  const container = renderer.domElement.parentElement;
292
+ if (!container) return;
293
  camera.aspect = container.clientWidth / container.clientHeight;
294
  camera.updateProjectionMatrix();
295
  renderer.setSize(container.clientWidth, container.clientHeight);
 
298
 
299
  function animate() {
300
  requestAnimationFrame(animate);
301
+ controls.update();
 
 
302
  composer.render();
303
  }
app/static/script.js CHANGED
@@ -1,6 +1,7 @@
1
- import { initUniverse } from './Universe3D.js';
2
  const API_URL = "/api";
3
 
 
4
  const toggleConfigBtn = document.getElementById("toggle-config-btn");
5
  const controlPanel = document.getElementById("control-panel");
6
  const configArrow = document.getElementById("config-arrow");
@@ -23,39 +24,26 @@ toggleConfigBtn.addEventListener("click", () => {
23
  }
24
  });
25
 
 
26
  let uploadedFiles = null;
27
  let currentSessionId = null;
28
  let currentGroups = {};
29
  let qualityScores = {};
30
  let currentClusterName = null;
31
-
32
- // Centralized chart management
33
- const chartInstances = {
34
- summary: null,
35
- distribution: null,
36
- distNew: null,
37
- ratioNew: null,
38
- };
39
-
40
- // Enhanced universe state management
41
  let universeState = {
42
- data: [],
43
- isRotating: false,
44
- rotationAnimationFrame: null,
45
- originalColors: [],
46
- lastSearchColors: null,
47
- lastSearchSizes: null,
48
- lastSearchLineColors: null,
49
- lastSearchLineWidths: null,
50
- isSearchActive: false,
51
- isInitialized: false,
52
  currentTab: "summary",
 
53
  };
54
 
 
55
  const loadingOverlay = document.getElementById("loading-overlay");
56
  const loadingText = document.getElementById("loading-text");
57
  const loadingBar = document.getElementById("loading-bar");
58
 
 
 
 
59
  document
60
  .getElementById("image-folder-input")
61
  .addEventListener("change", (e) => {
@@ -67,19 +55,21 @@ document
67
  }
68
  });
69
 
 
70
  document
71
  .getElementById("start-clustering-btn")
72
  .addEventListener("click", async () => {
73
- if (!uploadedFiles || uploadedFiles.length === 0) return alert("Please select a folder first.");
74
-
 
75
  if (isConfigOpen) toggleConfigBtn.click();
76
- document.body.classList.add("warp-active");
77
-
78
- await new Promise(r => setTimeout(r, 800));
79
-
80
  loadingOverlay.classList.remove("hidden");
81
- document.body.classList.remove("warp-active");
82
-
83
  const hero = document.getElementById("hero-landing");
84
  if (hero) hero.classList.add("hidden");
85
 
@@ -92,14 +82,13 @@ if (!uploadedFiles || uploadedFiles.length === 0) return alert("Please select a
92
  for (const f of uploadedFiles) {
93
  fd.append("files", f, f.webkitRelativePath || f.name);
94
  count++;
95
- // Smooth progress bar updates
96
  if (count % 5 === 0 || count === uploadedFiles.length) {
97
  loadingBar.style.width = `${10 + (count / uploadedFiles.length) * 40}%`;
98
- await new Promise((resolve) => setTimeout(resolve, 0)); // Allow UI update
99
  }
100
  }
101
 
102
- loadingText.textContent = "This may take a few minutes";
103
  loadingBar.style.width = "60%";
104
 
105
  try {
@@ -114,7 +103,7 @@ if (!uploadedFiles || uploadedFiles.length === 0) return alert("Please select a
114
  qualityScores = data.quality_scores || {};
115
 
116
  loadingBar.style.width = "100%";
117
- populateUI(data);
118
 
119
  setTimeout(() => loadingOverlay.classList.add("hidden"), 500);
120
  } catch (e) {
@@ -123,6 +112,59 @@ if (!uploadedFiles || uploadedFiles.length === 0) return alert("Please select a
123
  }
124
  });
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  function populateUI(data) {
127
  currentGroups = data.results.groups || {};
128
  const results = data.results;
@@ -130,347 +172,107 @@ function populateUI(data) {
130
  const unique = Object.keys(currentGroups).length;
131
  const dupes = total - unique;
132
 
 
 
 
 
 
133
  renderStatsDashboard(data, unique, dupes);
134
 
 
135
  const summaryContent = document.getElementById("summary-content");
136
  if (summaryContent) summaryContent.classList.add("hidden");
137
-
138
  const summaryVisuals = document.getElementById("summary-visuals");
139
  if (summaryVisuals) summaryVisuals.classList.remove("hidden");
140
 
141
  if (typeof renderActionCenter === "function") {
142
- renderActionCenter(unique, dupes, total);
143
  }
144
 
145
  renderClusterList();
146
-
 
147
  const dlBtn = document.getElementById("download-btn");
148
  if (dlBtn) {
149
- dlBtn.classList.remove("hidden");
150
- dlBtn.onclick = () => (window.location.href = `${API_URL}/download-results/${currentSessionId}`);
 
151
  }
152
-
153
  const delGrpBtn = document.getElementById("delete-group-btn");
154
  if (delGrpBtn) delGrpBtn.classList.remove("hidden");
155
 
 
156
  if (data.universe_map) renderUniverseMap(data.universe_map);
157
 
158
  const summaryTabBtn = document.querySelector('[data-tab="summary"]');
159
  if (summaryTabBtn) summaryTabBtn.click();
160
  }
161
 
162
- function renderStatsDashboard(data, unique, dupes) {
163
- document.getElementById("stats-empty").classList.add("hidden");
164
- document.getElementById("stats-content").classList.remove("hidden");
165
-
166
- const total = data.results.total_images;
167
- const saved = total ? ((dupes / total) * 100).toFixed(1) : 0;
168
- const perf = data.performance || {};
169
-
170
- document.getElementById("d-total").textContent = total;
171
- document.getElementById("d-dupes").textContent = dupes;
172
- document.getElementById("d-saved").textContent = `${saved}%`;
173
- document.getElementById("d-clusters").textContent = unique;
174
-
175
- if (chartInstances.distNew) {
176
- chartInstances.distNew.destroy();
177
- chartInstances.distNew = null;
178
- }
179
-
180
- const ctx1 = document.getElementById("chart-dist-new").getContext("2d");
181
- const bins = [0, 0, 0];
182
- Object.values(currentGroups).forEach((g) => {
183
- if (g.length <= 2) bins[0]++;
184
- else if (g.length <= 5) bins[1]++;
185
- else bins[2]++;
186
- });
187
- chartInstances.distNew = new Chart(ctx1, {
188
- type: "bar",
189
- data: {
190
- labels: ["Small (2)", "Medium (3-5)", "Large (6+)"],
191
- datasets: [
192
- {
193
- label: "Count",
194
- data: bins,
195
- backgroundColor: "#8b5cf6",
196
- borderRadius: 4,
197
- },
198
- ],
199
- },
200
- options: {
201
- maintainAspectRatio: false,
202
- plugins: { legend: { display: false } },
203
- scales: {
204
- y: { grid: { color: "#333" }, ticks: { color: "#aaa" } },
205
- x: { grid: { display: false }, ticks: { color: "#aaa" } },
206
- },
207
- },
208
- });
209
-
210
- // Destroy old charts properly
211
- if (chartInstances.ratioNew) {
212
- chartInstances.ratioNew.destroy();
213
- chartInstances.ratioNew = null;
214
- }
215
-
216
- const ctx2 = document.getElementById("chart-ratio-new").getContext("2d");
217
- chartInstances.ratioNew = new Chart(ctx2, {
218
- type: "doughnut",
219
- data: {
220
- labels: ["Unique", "Duplicate"],
221
- datasets: [
222
- {
223
- data: [unique, dupes],
224
- backgroundColor: ["#10b981", "#ef4444"],
225
- borderWidth: 0,
226
- },
227
- ],
228
- },
229
- options: {
230
- maintainAspectRatio: false,
231
- plugins: {
232
- legend: { position: "right", labels: { color: "#ccc" } },
233
- },
234
- },
235
- });
236
-
237
- const steps = [
238
- { name: "Feature Extraction", time: perf.extraction_time },
239
- { name: "Semantic Clustering", time: perf.stage1_cluster_time },
240
- { name: "Perceptual Hashing", time: perf.stage2_cluster_time },
241
- { name: "Quality Scoring", time: perf.quality_scoring_time },
242
- ];
243
- const pipeHTML = steps
244
- .map(
245
- (s) =>
246
- `<div class="pipeline-step done"><div class="flex justify-between mb-1"><span class="text-xs font-bold text-white">${
247
- s.name
248
- }</span><span class="text-xs text-emerald-400">${s.time?.toFixed(
249
- 2
250
- )}s</span></div></div>`
251
- )
252
- .join("");
253
- document.getElementById("pipeline-steps").innerHTML = pipeHTML;
254
-
255
- document.getElementById("log-console").innerText = `[INFO] Session ID: ${
256
- data.session_id
257
- }\n[INFO] Total Execution Time: ${perf.total_processing_time?.toFixed(
258
- 2
259
- )}s\n[INFO] Algorithm: ${
260
- document.getElementById("algorithm").value
261
- }\n[SUCCESS] Analysis complete. Found ${unique} clusters.`;
262
- }
263
-
264
- // Stop rotation properly
265
- function stopRotation() {
266
- universeState.isRotating = false;
267
- if (universeState.rotationAnimationFrame) {
268
- cancelAnimationFrame(universeState.rotationAnimationFrame);
269
- universeState.rotationAnimationFrame = null;
270
- }
271
- }
272
-
273
- // Start rotation with safety checks
274
- function startRotation() {
275
- if (universeState.currentTab !== "universe") return; // Don't rotate if not on universe tab
276
- if (!universeState.isInitialized) return; // Don't rotate if not initialized
277
- if (universeState.isRotating) return; // Already rotating
278
-
279
- universeState.isRotating = true;
280
- universeState.angle = universeState.angle || 0;
281
- rotateLoop();
282
- }
283
-
284
- function rotateLoop() {
285
- if (!universeState.isRotating || universeState.currentTab !== "universe") {
286
- stopRotation();
287
- return;
288
- }
289
-
290
- universeState.angle = (universeState.angle || 0) + 0.0015;
291
- const r = 2.2;
292
- const eyeX = r * Math.cos(universeState.angle);
293
- const eyeY = r * Math.sin(universeState.angle);
294
- const eyeZ = r * 0.9;
295
-
296
- try {
297
- Plotly.relayout("plotly-div", {
298
- "scene.camera.eye": { x: eyeX, y: eyeY, z: eyeZ },
299
- });
300
- universeState.rotationAnimationFrame = requestAnimationFrame(rotateLoop);
301
- } catch (e) {
302
- console.error("Rotation error:", e);
303
- stopRotation();
304
- }
305
- }
306
-
307
  function renderUniverseMap(points) {
308
- if (!points || !points.length) return;
309
-
310
- // Hiển thị UI
311
- document.getElementById("universe-empty").classList.add("hidden");
312
- document.getElementById("search-ui").classList.remove("hidden");
313
- document.getElementById("map-controls").classList.remove("hidden");
314
-
315
- // Gọi Three.js Engine
316
- // "plotly-div" id thẻ div chứa bản đồ (vẫn dùng lại thẻ div cũ)
317
- initUniverse("plotly-div", points, (nodeData) => {
318
- // Callback khi user click vào một ngôi sao
319
- if (nodeData.cluster && nodeData.cluster !== "Noise/Unique") {
320
- console.log("Warping to cluster:", nodeData.cluster);
321
-
322
- // Chuyển tab sang Browser
323
- const browserBtn = document.querySelector('[data-tab="browser"]');
324
- if(browserBtn) browserBtn.click();
325
-
326
- // Load cluster đó lên
327
- setTimeout(() => loadCluster(nodeData.cluster), 300);
328
- }
329
- });
330
-
331
- // Lắng nghe sự kiện hover từ Three.js để hiện Tooltip
332
- // (Vì module tách biệt nên dùng Event Dispatcher để giao tiếp)
333
- window.addEventListener('universe-hover', (e) => {
334
- const data = e.detail;
335
- const tooltip = document.getElementById("universe-tooltip");
336
-
337
- // Điền dữ liệu vào tooltip
338
- const imgPath = data.path.startsWith("/") ? data.path : `${API_URL}/results/${currentSessionId}/clusters/${data.path}`;
339
-
340
- document.getElementById("tooltip-img").src = imgPath;
341
- document.getElementById("tooltip-name").textContent = data.filename;
342
- document.getElementById("tooltip-cluster").textContent = data.cluster;
343
- document.getElementById("tooltip-score").textContent = data.quality ? data.quality.toFixed(0) : "N/A";
344
-
345
- tooltip.classList.remove("hidden");
346
-
347
- // Vị trí tooltip đơn giản theo chuột (có thể cần chỉnh offset trong CSS)
348
- // Lưu ý: e.detail không có clientX/Y, nên ta dùng biến global mouse move của document nếu cần
349
- // Hoặc trong code Three.js đã handle cursor pointer
350
- });
351
-
352
- // Ẩn tooltip khi chuột rời khỏi sao
353
- window.addEventListener('universe-unhover', () => {
354
- document.getElementById("universe-tooltip").classList.add("hidden");
355
- });
356
-
357
- // Cập nhật vị trí tooltip theo chuột (cho mượt)
358
- document.addEventListener('mousemove', (e) => {
359
- const tooltip = document.getElementById("universe-tooltip");
360
- if (!tooltip.classList.contains('hidden')) {
361
- tooltip.style.left = (e.clientX + 20) + 'px';
362
- tooltip.style.top = (e.clientY + 20) + 'px';
363
- }
364
- });
365
- }
366
-
367
- // Improved event handling - simpler approach
368
- function setupInteractions() {
369
- const plot = document.getElementById("plotly-div");
370
- const tooltip = document.getElementById("universe-tooltip");
371
-
372
- plot.on("plotly_hover", (d) => {
373
- if (universeState.isRotating) stopRotation();
374
-
375
- // Find the points trace (last trace, or first if no lines)
376
- const pointsTraceIndex = d.points[0].curveNumber;
377
- const hasLines = document.getElementById("toggle-lines").checked;
378
-
379
- // If we have lines, points are in trace 1, otherwise trace 0
380
- const expectedPointsIndex = hasLines ? 1 : 0;
381
-
382
- if (pointsTraceIndex !== expectedPointsIndex) return;
383
 
384
- const i = d.points[0].pointNumber;
385
- if (i >= universeState.data.length) return;
 
 
386
 
387
- const p = universeState.data[i];
388
- if (!p) return;
 
389
 
390
- document.getElementById(
391
- "tooltip-img"
392
- ).src = `${API_URL}/results/${currentSessionId}/clusters/${p.path}`;
393
- document.getElementById("tooltip-name").textContent =
394
- p.filename || "Unknown";
395
- document.getElementById("tooltip-cluster").textContent = p.cluster || "N/A";
396
- document.getElementById("tooltip-score").textContent = p.quality
397
- ? p.quality.toFixed(0)
398
  : "N/A";
399
 
400
  tooltip.classList.remove("hidden");
401
-
402
- // Better tooltip positioning with boundary checks
403
- const moveTooltip = (e) => {
404
- const rect = tooltip.getBoundingClientRect();
405
- let left = e.clientX + 20;
406
- let top = e.clientY + 20;
407
-
408
- // Prevent overflow
409
- if (left + rect.width > window.innerWidth) {
410
- left = e.clientX - rect.width - 20;
411
- }
412
- if (top + rect.height > window.innerHeight) {
413
- top = e.clientY - rect.height - 20;
414
- }
415
-
416
- tooltip.style.left = left + "px";
417
- tooltip.style.top = top + "px";
418
- };
419
-
420
- document.onmousemove = moveTooltip;
421
  });
422
 
423
- plot.on("plotly_unhover", () => {
424
- tooltip.classList.add("hidden");
425
- document.onmousemove = null;
426
-
427
- if (
428
- universeState.currentTab === "universe" &&
429
- document.getElementById("toggle-rotate").checked
430
- ) {
431
- setTimeout(() => startRotation(), 100);
432
- }
433
  });
434
 
435
- plot.on("plotly_click", (d) => {
436
- const hasLines = document.getElementById("toggle-lines").checked;
437
- const expectedPointsIndex = hasLines ? 1 : 0;
438
-
439
- if (d.points[0].curveNumber !== expectedPointsIndex) return;
440
-
441
- const i = d.points[0].pointNumber;
442
- if (i >= universeState.data.length) return;
443
-
444
- const p = universeState.data[i];
445
- if (p && p.cluster && p.cluster !== "Noise/Unique") {
446
- document.querySelector('[data-tab="browser"]').click();
447
- setTimeout(() => loadCluster(p.cluster), 100);
448
  }
449
  });
450
  }
451
 
452
- document.getElementById("btn-search").onclick = performSearch;
453
- document.getElementById("search-input").onkeypress = (e) => {
454
- if (e.key === "Enter") performSearch();
455
- };
456
-
457
  async function performSearch() {
458
  const q = document.getElementById("search-input").value.trim();
 
459
  if (!q) {
460
- // Properly reset search state
461
  universeState.isSearchActive = false;
462
- universeState.lastSearchColors = null;
463
- universeState.lastSearchSizes = null;
464
- universeState.lastSearchLineColors = null;
465
- universeState.lastSearchLineWidths = null;
466
- drawPlot(universeState.originalColors, null, false);
467
  return;
468
  }
469
 
470
  const btn = document.getElementById("btn-search");
471
  const originalText = btn.textContent;
472
- btn.textContent = "🔍";
473
- btn.style.opacity = "0.6";
474
  btn.disabled = true;
475
 
476
  try {
@@ -483,162 +285,46 @@ async function performSearch() {
483
  if (!res.ok) throw new Error("Search failed");
484
 
485
  const data = await res.json();
 
486
  if (data.results?.length) {
487
- const map = {};
488
- data.results.forEach((r) => (map[r.filename] = r.score));
489
-
490
- const colors = [];
491
- const sizes = [];
492
- const lineColors = [];
493
- const lineWidths = [];
494
-
495
- let best = null;
496
- let max = -1;
497
-
498
- universeState.data.forEach((p) => {
499
- const fname = p.filename.split("/").pop();
500
- if (map[fname] || map[p.filename]) {
501
- colors.push("#fbbf24");
502
- sizes.push(24);
503
- lineColors.push("rgba(251, 191, 36, 0.8)");
504
- lineWidths.push(8);
505
- const score = map[fname] || map[p.filename];
506
- if (score > max) {
507
- max = score;
508
- best = p;
509
- }
510
- } else {
511
- colors.push("rgba(255, 255, 255, 0.1)");
512
- sizes.push(8);
513
- lineColors.push("transparent");
514
- lineWidths.push(0);
515
- }
516
- });
517
 
518
  universeState.isSearchActive = true;
519
- universeState.lastSearchColors = colors;
520
- universeState.lastSearchSizes = sizes;
521
- universeState.lastSearchLineColors = lineColors;
522
- universeState.lastSearchLineWidths = lineWidths;
523
-
524
- drawPlot(colors, sizes, true, lineColors, lineWidths);
525
-
526
- if (best) {
527
- stopRotation();
528
- setTimeout(() => {
529
- Plotly.animate(
530
- "plotly-div",
531
- {
532
- layout: {
533
- "scene.camera": {
534
- eye: { x: best.x * 0.5, y: best.y * 0.5, z: best.z * 0.5 },
535
- center: { x: best.x, y: best.y, z: best.z },
536
- },
537
- },
538
- },
539
- {
540
- transition: { duration: 1200, easing: "cubic-in-out" },
541
- frame: { duration: 1200 },
542
- }
543
- );
544
- }, 100);
545
- }
546
  } else {
547
- alert("No matching images found.");
548
  }
549
  } catch (e) {
550
  console.error(e);
551
- alert("Search Failed: " + e.message);
552
  } finally {
553
  btn.textContent = originalText;
554
- btn.style.opacity = "1";
555
  btn.disabled = false;
556
  }
557
  }
558
 
559
- // Improved sync with proper color rebuild
560
  function syncUniverseMap(deletedPaths) {
561
  if (!universeState.data.length) return;
562
 
 
563
  universeState.data = universeState.data.filter(
564
  (p) => !deletedPaths.includes(p.path)
565
  );
566
 
567
- // Rebuild colors array to match filtered data
568
  if (universeState.data.length > 0) {
569
  renderUniverseMap(universeState.data);
570
  }
571
  }
572
 
573
- // Toggle handlers with proper state management
574
- document.getElementById("toggle-rotate").onchange = (e) => {
575
- if (e.target.checked && universeState.currentTab === "universe") {
576
- startRotation();
577
- } else {
578
- stopRotation();
579
- }
580
- };
581
-
582
- document.getElementById("toggle-lines").onchange = () => {
583
- if (universeState.isSearchActive) {
584
- drawPlot(
585
- universeState.lastSearchColors,
586
- universeState.lastSearchSizes,
587
- true,
588
- universeState.lastSearchLineColors,
589
- universeState.lastSearchLineWidths
590
- );
591
- } else {
592
- drawPlot(universeState.originalColors, null, false);
593
- }
594
- };
595
-
596
- // Improved tab switching with proper cleanup
597
- document.querySelectorAll(".tab-button").forEach((btn) => {
598
- btn.addEventListener("click", () => {
599
- const newTabId = btn.dataset.tab;
600
- const currentActiveContent = document.querySelector(".tab-content.fade-in");
601
- const newContent = document.getElementById(`tab-${newTabId}`);
602
-
603
- document.querySelectorAll(".tab-button").forEach((b) => b.classList.remove("active"));
604
- btn.classList.add("active");
605
-
606
- const showNewTab = () => {
607
- document.querySelectorAll(".tab-content").forEach(c => {
608
- c.classList.remove("active-tab", "fade-in");
609
- c.style.display = "none";
610
- });
611
-
612
- if (newContent.classList.contains("flex")) {
613
- newContent.style.display = "flex";
614
- } else {
615
- newContent.style.display = "block";
616
- }
617
-
618
- requestAnimationFrame(() => {
619
- newContent.classList.add("active-tab", "fade-in");
620
- });
621
-
622
- if (newTabId === "universe") {
623
- universeState.currentTab = "universe";
624
- setTimeout(() => {
625
- Plotly.Plots.resize("plotly-div");
626
- if (document.getElementById("toggle-rotate").checked) startRotation();
627
- }, 350);
628
- } else {
629
- stopRotation();
630
- universeState.currentTab = newTabId;
631
- }
632
- };
633
-
634
- if (currentActiveContent && currentActiveContent !== newContent) {
635
- currentActiveContent.classList.remove("fade-in");
636
- setTimeout(showNewTab, 300);
637
- } else {
638
- showNewTab();
639
- }
640
- });
641
- });
642
 
643
  function renderClusterList() {
644
  const list = document.getElementById("cluster-list");
@@ -658,7 +344,6 @@ function renderClusterList() {
658
 
659
  function loadCluster(name) {
660
  currentClusterName = name;
661
-
662
  document
663
  .querySelectorAll(".cluster-button")
664
  .forEach((b) => b.classList.remove("active"));
@@ -668,15 +353,14 @@ function loadCluster(name) {
668
 
669
  const gallery = document.getElementById("thumbnail-gallery");
670
  gallery.innerHTML = "";
671
- document.getElementById(
672
- "thumbnail-header"
673
- ).textContent = `Cluster Content: ${name}`;
674
 
675
  const q = qualityScores[name]?.images || [];
676
 
677
- document.getElementById("delete-btn").disabled = false;
678
- document.getElementById("move-btn").disabled = false;
679
- document.getElementById("smart-cleanup-btn").disabled = false;
 
680
 
681
  currentGroups[name].forEach((path, index) => {
682
  const url = `${API_URL}/results/${currentSessionId}/clusters/${path}`;
@@ -684,10 +368,7 @@ function loadCluster(name) {
684
  const isBest = info?.is_best;
685
 
686
  const div = document.createElement("div");
687
-
688
- const delay = Math.min(index * 30, 1000);
689
- div.style.animationDelay = `${delay}ms`;
690
-
691
  div.className = `thumbnail-card rounded p-2 flex flex-col relative group ${
692
  isBest ? "best-quality" : ""
693
  }`;
@@ -700,37 +381,26 @@ function loadCluster(name) {
700
  info
701
  ? `<div class="quality-badge" style="background:${
702
  info.quality_color
703
- }">${info.scores.total.toFixed(0)}
704
- <div class="quality-details"><div class="quality-metric"><span class="quality-metric-label">Res</span><span class="quality-metric-value">${
705
- info.scores.resolution
706
- }</span></div><div class="quality-metric"><span class="quality-metric-label">Sharp</span><span class="quality-metric-value">${
707
- info.scores.sharpness
708
- }</span></div></div>
709
- </div>`
710
  : ""
711
  }
712
  <div class="relative overflow-hidden rounded aspect-square bg-black">
713
- <img src="${url}" loading="lazy" class="w-full h-full object-contain transition-transform duration-300 group-hover:scale-110 cursor-zoom-in">
714
  </div>
715
  <div class="text-[10px] text-gray-400 truncate mt-2 font-mono text-center">${path
716
  .split("/")
717
  .pop()}</div>
718
  `;
719
 
720
- const img = div.querySelector('img');
721
  img.onclick = (e) => {
722
- e.stopPropagation();
723
- const modal = document.getElementById('image-modal');
724
- const modalImg = document.getElementById('modal-image');
725
-
726
- modalImg.src = url;
727
-
728
- modalImg.classList.remove('show');
729
- modal.classList.remove('hidden');
730
-
731
- setTimeout(() => {
732
- modalImg.classList.add('show');
733
- }, 50);
734
  };
735
 
736
  div.querySelector("input").onclick = (e) => {
@@ -742,68 +412,59 @@ function loadCluster(name) {
742
  });
743
  }
744
 
 
 
745
  document.getElementById("delete-btn").onclick = async () => {
746
- const selectedCards = Array.from(document.querySelectorAll(".thumbnail-card.selected"));
 
 
747
  const paths = selectedCards.map((c) => c.dataset.path);
 
 
748
 
749
- if (!paths.length) return alert("Please select images to delete.");
750
-
751
- if (!confirm(`Initialize deletion sequence for ${paths.length} targets?`)) return;
752
-
753
- selectedCards.forEach(card => card.classList.add("being-deleted"));
754
 
755
  try {
756
- const [res, _] = await Promise.all([
757
- fetch(`${API_URL}/delete-images`, {
758
- method: "POST",
759
- headers: { "Content-Type": "application/json" },
760
- body: JSON.stringify({
761
- session_id: currentSessionId,
762
- image_paths: paths,
763
- }),
764
- }),
765
- new Promise(resolve => setTimeout(resolve, 800))
766
- ]);
767
-
768
- if (!res.ok) {
769
- const errData = await res.json();
770
- throw new Error(errData.detail || "Deletion failed on server.");
771
- }
772
-
773
  const data = await res.json();
774
 
775
- selectedCards.forEach(card => card.remove());
776
-
777
  if (currentClusterName && currentGroups[currentClusterName]) {
778
- currentGroups[currentClusterName] = currentGroups[currentClusterName].filter(p => !paths.includes(p));
 
 
779
  }
780
-
781
- syncUniverseMap(data.deleted);
782
-
783
  } catch (e) {
784
- console.error(e);
785
- selectedCards.forEach(card => card.classList.remove("being-deleted"));
786
- alert("Anomaly Detected: " + e.message);
787
  }
788
  };
789
 
790
  document.getElementById("smart-cleanup-btn").onclick = async () => {
791
  const sel = document.querySelectorAll(".thumbnail-card.selected");
792
- if (sel.length !== 1) return alert("Select exactly ONE image to keep as the best version.");
793
-
794
  const keepPath = sel[0].dataset.path;
795
- const keepName = keepPath.split("/").pop();
796
-
797
- if (!confirm(`Keep '${keepName}' and DELETE ALL OTHERS in this cluster?`)) return;
798
 
799
  const allCards = Array.from(document.querySelectorAll(".thumbnail-card"));
800
- const cardsToDelete = allCards.filter(card => card.dataset.path !== keepPath);
 
 
 
801
 
802
- cardsToDelete.forEach(card => card.classList.add("being-deleted"));
803
-
804
  const btn = document.getElementById("smart-cleanup-btn");
805
  btn.disabled = true;
806
- btn.textContent = "Cleaning...";
807
 
808
  try {
809
  const res = await fetch(`${API_URL}/smart-cleanup`, {
@@ -815,32 +476,29 @@ document.getElementById("smart-cleanup-btn").onclick = async () => {
815
  image_to_keep: keepPath,
816
  }),
817
  });
818
-
819
  if (!res.ok) throw new Error((await res.json()).detail);
820
  const data = await res.json();
821
 
822
- await new Promise(resolve => setTimeout(resolve, 800));
823
 
824
  const oldPaths = currentGroups[currentClusterName];
825
  currentGroups[currentClusterName] = [data.image_kept];
826
-
827
- const deletedPaths = oldPaths.filter(p => p !== data.image_kept);
828
  syncUniverseMap(deletedPaths);
829
 
830
  loadCluster(currentClusterName);
831
  renderClusterList();
832
-
833
  } catch (e) {
834
- cardsToDelete.forEach(card => card.classList.remove("being-deleted"));
835
- alert("Smart cleanup failed: " + e.message);
836
  } finally {
837
  btn.disabled = false;
838
- btn.textContent = "Keep Selected & Delete Rest";
839
  }
840
  };
841
 
842
  document.getElementById("delete-group-btn").onclick = async () => {
843
- if (!confirm(`Delete group ${currentClusterName}?`)) return;
844
  try {
845
  const res = await fetch(`${API_URL}/delete-group`, {
846
  method: "POST",
@@ -859,142 +517,68 @@ document.getElementById("delete-group-btn").onclick = async () => {
859
  syncUniverseMap(deletedPaths);
860
  }
861
  } catch (e) {
862
- alert("Delete group failed: " + e.message);
863
  }
864
  };
865
 
866
- document.getElementById("move-btn").onclick = () => {
867
- const paths = Array.from(
868
- document.querySelectorAll(".thumbnail-card.selected")
869
- ).map((c) => c.dataset.path);
870
- if (!paths.length) return alert("Select images to move.");
871
- const sel = document.getElementById("move-cluster-select");
872
- sel.innerHTML = '<option value="__NEW__">-- New Cluster --</option>';
873
- Object.keys(currentGroups).forEach((g) => {
874
- if (g !== currentClusterName)
875
- sel.innerHTML += `<option value="${g}">${g}</option>`;
876
- });
877
- document.getElementById("move-modal").classList.remove("hidden");
878
- };
879
-
880
- document.getElementById("move-cancel-btn").onclick = () =>
881
- document.getElementById("move-modal").classList.add("hidden");
882
-
883
- document.getElementById("move-cluster-select").onchange = (e) =>
884
- document
885
- .getElementById("move-new-cluster-input-group")
886
- .classList.toggle("hidden", e.target.value !== "__NEW__");
887
-
888
- document.getElementById("move-confirm-btn").onclick = async () => {
889
- const paths = Array.from(
890
- document.querySelectorAll(".thumbnail-card.selected")
891
- ).map((c) => c.dataset.path);
892
- let dest = document.getElementById("move-cluster-select").value;
893
- if (dest === "__NEW__")
894
- dest = document.getElementById("move-new-cluster-name").value.trim();
895
- if (!dest) return alert("Invalid name");
896
-
897
- document.getElementById("move-modal").classList.add("hidden");
898
- try {
899
- const res = await fetch(`${API_URL}/move-images`, {
900
- method: "POST",
901
- headers: { "Content-Type": "application/json" },
902
- body: JSON.stringify({
903
- session_id: currentSessionId,
904
- image_paths: paths,
905
- destination_cluster: dest,
906
- }),
907
- });
908
- const data = await res.json();
909
- currentGroups[currentClusterName] = currentGroups[
910
- currentClusterName
911
- ].filter((p) => !paths.includes(p));
912
- if (!currentGroups[dest]) currentGroups[dest] = [];
913
- currentGroups[dest].push(
914
- ...data.moved.map((p) =>
915
- p.includes("/") ? p : `${dest}/${p.split("/").pop()}`
916
- )
917
- );
918
- loadCluster(currentClusterName);
919
- renderClusterList();
920
 
921
- // Update universe map clusters properly
922
- universeState.data.forEach((p) => {
923
- const fileName = p.path.split("/").pop();
924
- if (paths.some((movedPath) => movedPath.endsWith(fileName))) {
925
- p.cluster = dest;
926
- }
927
- });
928
- renderUniverseMap(universeState.data);
929
- } catch (e) {
930
- alert(e);
931
- }
932
- };
933
 
934
- // Destroy old charts properly
935
- function renderCharts(unique, dupes, groups) {
936
- if (chartInstances.summary) {
937
- chartInstances.summary.destroy();
938
- chartInstances.summary = null;
939
- }
940
 
941
- const ctx1 = document.getElementById("summary-chart").getContext("2d");
942
- chartInstances.summary = new Chart(ctx1, {
 
 
943
  type: "doughnut",
944
  data: {
945
  labels: ["Unique", "Duplicate"],
946
  datasets: [
947
  {
948
  data: [unique, dupes],
949
- backgroundColor: ["#3b82f6", "#ef4444"],
950
  borderWidth: 0,
951
  },
952
  ],
953
  },
954
  options: {
955
  maintainAspectRatio: false,
956
- plugins: {
957
- legend: { position: "bottom", labels: { color: "#ccc" } },
958
- },
959
  },
960
  });
961
 
962
- const bins = [0, 0, 0];
963
- Object.values(groups).forEach((g) => {
964
- if (g.length <= 2) bins[0]++;
965
- else if (g.length <= 5) bins[1]++;
966
- else bins[2]++;
967
- });
968
-
969
- if (chartInstances.distribution) {
970
- chartInstances.distribution.destroy();
971
- chartInstances.distribution = null;
972
- }
973
-
974
- const ctx2 = document.getElementById("distribution-chart").getContext("2d");
975
- chartInstances.distribution = new Chart(ctx2, {
976
- type: "bar",
977
- data: {
978
- labels: ["Small", "Medium", "Large"],
979
- datasets: [{ label: "Clusters", data: bins, backgroundColor: "#8b5cf6" }],
980
- },
981
- options: {
982
- maintainAspectRatio: false,
983
- plugins: { legend: { display: false } },
984
- scales: {
985
- y: { grid: { color: "#333" }, ticks: { color: "#aaa" } },
986
- x: { grid: { display: false }, ticks: { color: "#aaa" } },
987
- },
988
- },
989
- });
990
  }
991
 
 
992
  function renderActionCenter(unique, dupes, total) {
993
- document.getElementById("summary-session-id").textContent = currentSessionId || "N/A";
994
-
995
- const savings = total > 0 ? ((dupes / total) * 100).toFixed(1) + "%" : "0%";
996
- document.getElementById("summary-savings-text").textContent = savings;
997
- document.getElementById("summary-impact-badge").classList.remove("hidden");
998
 
999
  document.getElementById("sum-total-img").textContent = total;
1000
  document.getElementById("sum-dupes-img").textContent = dupes;
@@ -1007,97 +591,52 @@ function renderActionCenter(unique, dupes, total) {
1007
  .sort((a, b) => b[1].length - a[1].length)
1008
  .slice(0, 8);
1009
 
1010
- if (sortedGroups.length === 0) {
1011
- grid.innerHTML = `<div class="col-span-full text-center text-gray-500 italic">No duplicate clusters found. Clean sweep!</div>`;
1012
- return;
1013
- }
1014
-
1015
  sortedGroups.forEach(([name, files], index) => {
1016
- const thumbPath = files[0];
1017
- const url = `${API_URL}/results/${currentSessionId}/clusters/${thumbPath}`;
1018
-
1019
- const isHighPriority = index < 2;
1020
- const count = files.length;
1021
-
1022
  const div = document.createElement("div");
1023
- div.className = `highlight-card rounded-xl p-4 cursor-pointer group flex flex-col gap-3 ${isHighPriority ? 'ring-1 ring-violet-500/30' : ''}`;
1024
-
1025
- const wasteLevel = Math.min(count * 10, 100);
1026
-
1027
  div.innerHTML = `
1028
- <div class="flex justify-between items-start">
1029
- <div class="flex flex-col overflow-hidden">
1030
- <span class="text-white font-bold text-sm truncate w-full" title="${name}">${name}</span>
1031
- <span class="text-[10px] text-gray-500 font-mono">ID: ${index + 1}</span>
1032
- </div>
1033
- <span class="impact-badge text-[10px] font-bold px-2 py-1 rounded">
1034
- ${count} ITEMS
1035
- </span>
1036
- </div>
1037
-
1038
- <div class="highlight-img-container aspect-video w-full bg-black rounded-lg border border-[#333]">
1039
- <img src="${url}" class="w-full h-full object-contain opacity-80 group-hover:opacity-100 transition-opacity duration-300" loading="lazy">
1040
- </div>
1041
-
1042
- <div class="w-full bg-[#333] h-1 rounded-full overflow-hidden mt-1">
1043
- <div class="bg-gradient-to-r from-violet-600 to-indigo-500 h-full" style="width: ${wasteLevel}%"></div>
1044
- </div>
1045
- `;
1046
-
1047
  div.onclick = () => {
1048
- const browserBtn = document.querySelector('[data-tab="browser"]');
1049
- browserBtn.click();
1050
-
1051
- setTimeout(() => {
1052
- loadCluster(name);
1053
- document.getElementById("thumbnail-gallery").scrollTop = 0;
1054
- }, 150);
1055
  };
1056
-
1057
  grid.appendChild(div);
1058
  });
1059
  }
1060
 
 
1061
  document.getElementById("select-all-btn").onclick = () =>
1062
  document.querySelectorAll(".thumbnail-card").forEach((c) => {
1063
  c.classList.add("selected");
1064
  c.querySelector("input").checked = true;
1065
  });
1066
-
1067
  document.getElementById("deselect-all-btn").onclick = () =>
1068
  document.querySelectorAll(".thumbnail-card").forEach((c) => {
1069
  c.classList.remove("selected");
1070
  c.querySelector("input").checked = false;
1071
  });
1072
-
1073
  document.getElementById("keep-best-btn").onclick = () => {
1074
  const best = qualityScores[currentClusterName]?.images.find((i) => i.is_best);
1075
  if (best) {
1076
- document.querySelectorAll(".thumbnail-card").forEach((c) => {
1077
- c.classList.remove("selected");
1078
- c.querySelector("input").checked = false;
1079
- });
1080
- const bestCard = document.querySelector(`[data-path="${best.path}"]`);
1081
- if (bestCard) {
1082
- bestCard.classList.add("selected");
1083
- bestCard.querySelector("input").checked = true;
1084
  }
1085
  }
1086
  };
1087
-
1088
- document.getElementById("image-modal").onclick = (e) => {
1089
- if (e.target.id === "image-modal") {
1090
- e.target.classList.add("hidden");
1091
- }
1092
  };
1093
-
1094
- window.addEventListener("beforeunload", () => {
1095
- stopRotation();
1096
- Object.values(chartInstances).forEach((chart) => {
1097
- if (chart) chart.destroy();
1098
- });
1099
- });
1100
-
1101
- // Initialize with summary tab
1102
- universeState.currentTab = "summary";
1103
- document.querySelector('[data-tab="summary"]')?.classList.add("active");
 
1
+ import { initUniverse } from "./Universe3D.js";
2
  const API_URL = "/api";
3
 
4
+ // --- UI CONFIG TOGGLE ---
5
  const toggleConfigBtn = document.getElementById("toggle-config-btn");
6
  const controlPanel = document.getElementById("control-panel");
7
  const configArrow = document.getElementById("config-arrow");
 
24
  }
25
  });
26
 
27
+ // --- GLOBAL STATE ---
28
  let uploadedFiles = null;
29
  let currentSessionId = null;
30
  let currentGroups = {};
31
  let qualityScores = {};
32
  let currentClusterName = null;
 
 
 
 
 
 
 
 
 
 
33
  let universeState = {
34
+ data: [], // Dữ liệu 3D sẽ được lưu ở đây
 
 
 
 
 
 
 
 
 
35
  currentTab: "summary",
36
+ isSearchActive: false,
37
  };
38
 
39
+ const chartInstances = { distNew: null, ratioNew: null };
40
  const loadingOverlay = document.getElementById("loading-overlay");
41
  const loadingText = document.getElementById("loading-text");
42
  const loadingBar = document.getElementById("loading-bar");
43
 
44
+ // --- EVENT LISTENERS ---
45
+
46
+ // 1. File Input
47
  document
48
  .getElementById("image-folder-input")
49
  .addEventListener("change", (e) => {
 
55
  }
56
  });
57
 
58
+ // 2. Start Processing
59
  document
60
  .getElementById("start-clustering-btn")
61
  .addEventListener("click", async () => {
62
+ if (!uploadedFiles || uploadedFiles.length === 0)
63
+ return alert("Please select a folder first.");
64
+
65
  if (isConfigOpen) toggleConfigBtn.click();
66
+ document.body.classList.add("warp-active"); // Hiệu ứng Warp Speed
67
+
68
+ await new Promise((r) => setTimeout(r, 800));
69
+
70
  loadingOverlay.classList.remove("hidden");
71
+ document.body.classList.remove("warp-active");
72
+
73
  const hero = document.getElementById("hero-landing");
74
  if (hero) hero.classList.add("hidden");
75
 
 
82
  for (const f of uploadedFiles) {
83
  fd.append("files", f, f.webkitRelativePath || f.name);
84
  count++;
 
85
  if (count % 5 === 0 || count === uploadedFiles.length) {
86
  loadingBar.style.width = `${10 + (count / uploadedFiles.length) * 40}%`;
87
+ await new Promise((resolve) => setTimeout(resolve, 0));
88
  }
89
  }
90
 
91
+ loadingText.textContent = "Processing dimensions...";
92
  loadingBar.style.width = "60%";
93
 
94
  try {
 
103
  qualityScores = data.quality_scores || {};
104
 
105
  loadingBar.style.width = "100%";
106
+ populateUI(data); // Gọi hàm hiển thị dữ liệu
107
 
108
  setTimeout(() => loadingOverlay.classList.add("hidden"), 500);
109
  } catch (e) {
 
112
  }
113
  });
114
 
115
+ // 3. Tab Switching
116
+ document.querySelectorAll(".tab-button").forEach((btn) => {
117
+ btn.addEventListener("click", () => {
118
+ const newTabId = btn.dataset.tab;
119
+ const currentActiveContent = document.querySelector(".tab-content.fade-in");
120
+ const newContent = document.getElementById(`tab-${newTabId}`);
121
+
122
+ document
123
+ .querySelectorAll(".tab-button")
124
+ .forEach((b) => b.classList.remove("active"));
125
+ btn.classList.add("active");
126
+
127
+ const showNewTab = () => {
128
+ document.querySelectorAll(".tab-content").forEach((c) => {
129
+ c.classList.remove("active-tab", "fade-in");
130
+ c.style.display = "none";
131
+ });
132
+
133
+ if (newContent.classList.contains("flex")) {
134
+ newContent.style.display = "flex";
135
+ } else {
136
+ newContent.style.display = "block";
137
+ }
138
+
139
+ requestAnimationFrame(() => {
140
+ newContent.classList.add("active-tab", "fade-in");
141
+ });
142
+
143
+ if (newTabId === "universe") {
144
+ universeState.currentTab = "universe";
145
+ window.dispatchEvent(new Event("resize")); // Resize Three.js
146
+ } else {
147
+ universeState.currentTab = newTabId;
148
+ }
149
+ };
150
+
151
+ if (currentActiveContent && currentActiveContent !== newContent) {
152
+ currentActiveContent.classList.remove("fade-in");
153
+ setTimeout(showNewTab, 300);
154
+ } else {
155
+ showNewTab();
156
+ }
157
+ });
158
+ });
159
+
160
+ // 4. Search & Controls
161
+ document.getElementById("btn-search").onclick = performSearch;
162
+ document.getElementById("search-input").onkeypress = (e) => {
163
+ if (e.key === "Enter") performSearch();
164
+ };
165
+
166
+ // --- CORE FUNCTIONS ---
167
+
168
  function populateUI(data) {
169
  currentGroups = data.results.groups || {};
170
  const results = data.results;
 
172
  const unique = Object.keys(currentGroups).length;
173
  const dupes = total - unique;
174
 
175
+ // [QUAN TRỌNG] Lưu dữ liệu vào State để dùng sau này
176
+ if (data.universe_map) {
177
+ universeState.data = data.universe_map;
178
+ }
179
+
180
  renderStatsDashboard(data, unique, dupes);
181
 
182
+ // Ẩn/Hiện các phần UI
183
  const summaryContent = document.getElementById("summary-content");
184
  if (summaryContent) summaryContent.classList.add("hidden");
185
+
186
  const summaryVisuals = document.getElementById("summary-visuals");
187
  if (summaryVisuals) summaryVisuals.classList.remove("hidden");
188
 
189
  if (typeof renderActionCenter === "function") {
190
+ renderActionCenter(unique, dupes, total);
191
  }
192
 
193
  renderClusterList();
194
+
195
+ // Setup Download buttons
196
  const dlBtn = document.getElementById("download-btn");
197
  if (dlBtn) {
198
+ dlBtn.classList.remove("hidden");
199
+ dlBtn.onclick = () =>
200
+ (window.location.href = `${API_URL}/download-results/${currentSessionId}`);
201
  }
 
202
  const delGrpBtn = document.getElementById("delete-group-btn");
203
  if (delGrpBtn) delGrpBtn.classList.remove("hidden");
204
 
205
+ // Render 3D Map
206
  if (data.universe_map) renderUniverseMap(data.universe_map);
207
 
208
  const summaryTabBtn = document.querySelector('[data-tab="summary"]');
209
  if (summaryTabBtn) summaryTabBtn.click();
210
  }
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  function renderUniverseMap(points) {
213
+ if (!points || !points.length) return;
214
+
215
+ document.getElementById("universe-empty").classList.add("hidden");
216
+ document.getElementById("search-ui").classList.remove("hidden");
217
+ document.getElementById("map-controls").classList.remove("hidden");
218
+
219
+ // Khởi tạo Three.js
220
+ initUniverse("plotly-div", points, (nodeData) => {
221
+ // Callback khi Click vào sao
222
+ if (nodeData.cluster && nodeData.cluster !== "Noise/Unique") {
223
+ console.log("Warping to cluster:", nodeData.cluster);
224
+
225
+ // Chuyển tab và Load ảnh
226
+ const browserBtn = document.querySelector('[data-tab="browser"]');
227
+ if (browserBtn) browserBtn.click();
228
+ setTimeout(() => loadCluster(nodeData.cluster), 300);
229
+ }
230
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
+ // Tooltip Events
233
+ window.addEventListener("universe-hover", (e) => {
234
+ const data = e.detail;
235
+ const tooltip = document.getElementById("universe-tooltip");
236
 
237
+ const imgPath = data.path.startsWith("/")
238
+ ? data.path
239
+ : `${API_URL}/results/${currentSessionId}/clusters/${data.path}`;
240
 
241
+ document.getElementById("tooltip-img").src = imgPath;
242
+ document.getElementById("tooltip-name").textContent = data.filename;
243
+ document.getElementById("tooltip-cluster").textContent = data.cluster;
244
+ document.getElementById("tooltip-score").textContent = data.quality
245
+ ? data.quality.toFixed(0)
 
 
 
246
  : "N/A";
247
 
248
  tooltip.classList.remove("hidden");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  });
250
 
251
+ window.addEventListener("universe-unhover", () => {
252
+ document.getElementById("universe-tooltip").classList.add("hidden");
 
 
 
 
 
 
 
 
253
  });
254
 
255
+ document.addEventListener("mousemove", (e) => {
256
+ const tooltip = document.getElementById("universe-tooltip");
257
+ if (!tooltip.classList.contains("hidden")) {
258
+ tooltip.style.left = e.clientX + 20 + "px";
259
+ tooltip.style.top = e.clientY + 20 + "px";
 
 
 
 
 
 
 
 
260
  }
261
  });
262
  }
263
 
 
 
 
 
 
264
  async function performSearch() {
265
  const q = document.getElementById("search-input").value.trim();
266
+
267
  if (!q) {
 
268
  universeState.isSearchActive = false;
269
+ window.dispatchEvent(new CustomEvent("universe-search-reset"));
 
 
 
 
270
  return;
271
  }
272
 
273
  const btn = document.getElementById("btn-search");
274
  const originalText = btn.textContent;
275
+ btn.textContent = "...";
 
276
  btn.disabled = true;
277
 
278
  try {
 
285
  if (!res.ok) throw new Error("Search failed");
286
 
287
  const data = await res.json();
288
+
289
  if (data.results?.length) {
290
+ const matchedFilenames = data.results.map((r) => r.filename);
291
+ const bestMatch = data.results[0];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
 
293
  universeState.isSearchActive = true;
294
+
295
+ // Gửi event sang Three.js
296
+ window.dispatchEvent(
297
+ new CustomEvent("universe-search", {
298
+ detail: { filenames: matchedFilenames },
299
+ })
300
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  } else {
302
+ alert("No visual matches found.");
303
  }
304
  } catch (e) {
305
  console.error(e);
306
+ alert("Search Error: " + e.message);
307
  } finally {
308
  btn.textContent = originalText;
 
309
  btn.disabled = false;
310
  }
311
  }
312
 
 
313
  function syncUniverseMap(deletedPaths) {
314
  if (!universeState.data.length) return;
315
 
316
+ // Lọc bỏ ảnh đã xóa
317
  universeState.data = universeState.data.filter(
318
  (p) => !deletedPaths.includes(p.path)
319
  );
320
 
321
+ // Vẽ lại trụ
322
  if (universeState.data.length > 0) {
323
  renderUniverseMap(universeState.data);
324
  }
325
  }
326
 
327
+ // --- CLUSTER BROWSER LOGIC (Giữ nguyên logic cũ) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
  function renderClusterList() {
330
  const list = document.getElementById("cluster-list");
 
344
 
345
  function loadCluster(name) {
346
  currentClusterName = name;
 
347
  document
348
  .querySelectorAll(".cluster-button")
349
  .forEach((b) => b.classList.remove("active"));
 
353
 
354
  const gallery = document.getElementById("thumbnail-gallery");
355
  gallery.innerHTML = "";
356
+ document.getElementById("thumbnail-header").textContent = `Cluster: ${name}`;
 
 
357
 
358
  const q = qualityScores[name]?.images || [];
359
 
360
+ // Enable buttons
361
+ ["delete-btn", "move-btn", "smart-cleanup-btn"].forEach(
362
+ (id) => (document.getElementById(id).disabled = false)
363
+ );
364
 
365
  currentGroups[name].forEach((path, index) => {
366
  const url = `${API_URL}/results/${currentSessionId}/clusters/${path}`;
 
368
  const isBest = info?.is_best;
369
 
370
  const div = document.createElement("div");
371
+ div.style.animationDelay = `${Math.min(index * 30, 1000)}ms`;
 
 
 
372
  div.className = `thumbnail-card rounded p-2 flex flex-col relative group ${
373
  isBest ? "best-quality" : ""
374
  }`;
 
381
  info
382
  ? `<div class="quality-badge" style="background:${
383
  info.quality_color
384
+ }">${info.scores.total.toFixed(0)}</div>`
 
 
 
 
 
 
385
  : ""
386
  }
387
  <div class="relative overflow-hidden rounded aspect-square bg-black">
388
+ <img src="${url}" loading="lazy" class="w-full h-full object-contain transition-transform duration-300 group-hover:scale-110">
389
  </div>
390
  <div class="text-[10px] text-gray-400 truncate mt-2 font-mono text-center">${path
391
  .split("/")
392
  .pop()}</div>
393
  `;
394
 
395
+ const img = div.querySelector("img");
396
  img.onclick = (e) => {
397
+ e.stopPropagation();
398
+ const modal = document.getElementById("image-modal");
399
+ const modalImg = document.getElementById("modal-image");
400
+ modalImg.src = url;
401
+ modalImg.classList.remove("show");
402
+ modal.classList.remove("hidden");
403
+ setTimeout(() => modalImg.classList.add("show"), 50);
 
 
 
 
 
404
  };
405
 
406
  div.querySelector("input").onclick = (e) => {
 
412
  });
413
  }
414
 
415
+ // --- ACTION LOGIC (Delete, Move, Keep Best) ---
416
+
417
  document.getElementById("delete-btn").onclick = async () => {
418
+ const selectedCards = Array.from(
419
+ document.querySelectorAll(".thumbnail-card.selected")
420
+ );
421
  const paths = selectedCards.map((c) => c.dataset.path);
422
+ if (!paths.length) return;
423
+ if (!confirm(`Delete ${paths.length} items?`)) return;
424
 
425
+ selectedCards.forEach((card) => card.classList.add("being-deleted")); // Hiệu ứng lỗ đen
 
 
 
 
426
 
427
  try {
428
+ const res = await fetch(`${API_URL}/delete-images`, {
429
+ method: "POST",
430
+ headers: { "Content-Type": "application/json" },
431
+ body: JSON.stringify({
432
+ session_id: currentSessionId,
433
+ image_paths: paths,
434
+ }),
435
+ });
436
+ if (!res.ok) throw new Error("Server error");
 
 
 
 
 
 
 
 
437
  const data = await res.json();
438
 
439
+ selectedCards.forEach((card) => card.remove());
 
440
  if (currentClusterName && currentGroups[currentClusterName]) {
441
+ currentGroups[currentClusterName] = currentGroups[
442
+ currentClusterName
443
+ ].filter((p) => !paths.includes(p));
444
  }
445
+ syncUniverseMap(data.deleted); // Cập nhật bản đồ 3D
 
 
446
  } catch (e) {
447
+ selectedCards.forEach((card) => card.classList.remove("being-deleted"));
448
+ alert("Delete failed: " + e.message);
 
449
  }
450
  };
451
 
452
  document.getElementById("smart-cleanup-btn").onclick = async () => {
453
  const sel = document.querySelectorAll(".thumbnail-card.selected");
454
+ if (sel.length !== 1) return alert("Select exactly ONE best image to keep.");
455
+
456
  const keepPath = sel[0].dataset.path;
457
+ if (!confirm(`Keep 1 and delete the rest of '${currentClusterName}'?`))
458
+ return;
 
459
 
460
  const allCards = Array.from(document.querySelectorAll(".thumbnail-card"));
461
+ const cardsToDelete = allCards.filter(
462
+ (card) => card.dataset.path !== keepPath
463
+ );
464
+ cardsToDelete.forEach((card) => card.classList.add("being-deleted"));
465
 
 
 
466
  const btn = document.getElementById("smart-cleanup-btn");
467
  btn.disabled = true;
 
468
 
469
  try {
470
  const res = await fetch(`${API_URL}/smart-cleanup`, {
 
476
  image_to_keep: keepPath,
477
  }),
478
  });
479
+
480
  if (!res.ok) throw new Error((await res.json()).detail);
481
  const data = await res.json();
482
 
483
+ await new Promise((r) => setTimeout(r, 800)); // Đợi hiệu ứng
484
 
485
  const oldPaths = currentGroups[currentClusterName];
486
  currentGroups[currentClusterName] = [data.image_kept];
487
+ const deletedPaths = oldPaths.filter((p) => p !== data.image_kept);
 
488
  syncUniverseMap(deletedPaths);
489
 
490
  loadCluster(currentClusterName);
491
  renderClusterList();
 
492
  } catch (e) {
493
+ cardsToDelete.forEach((card) => card.classList.remove("being-deleted"));
494
+ alert(e.message);
495
  } finally {
496
  btn.disabled = false;
 
497
  }
498
  };
499
 
500
  document.getElementById("delete-group-btn").onclick = async () => {
501
+ if (!confirm(`Delete entire group ${currentClusterName}?`)) return;
502
  try {
503
  const res = await fetch(`${API_URL}/delete-group`, {
504
  method: "POST",
 
517
  syncUniverseMap(deletedPaths);
518
  }
519
  } catch (e) {
520
+ alert(e.message);
521
  }
522
  };
523
 
524
+ // --- STATS & CHARTS ---
525
+ function renderStatsDashboard(data, unique, dupes) {
526
+ document.getElementById("stats-empty").classList.add("hidden");
527
+ document.getElementById("stats-content").classList.remove("hidden");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
 
529
+ const total = data.results.total_images;
530
+ const saved = total ? ((dupes / total) * 100).toFixed(1) : 0;
 
 
 
 
 
 
 
 
 
 
531
 
532
+ document.getElementById("d-total").textContent = total;
533
+ document.getElementById("d-dupes").textContent = dupes;
534
+ document.getElementById("d-saved").textContent = `${saved}%`;
535
+ document.getElementById("d-clusters").textContent = unique;
 
 
536
 
537
+ // Chart Logic (Giản lược để code gọn)
538
+ if (chartInstances.ratioNew) chartInstances.ratioNew.destroy();
539
+ const ctx = document.getElementById("chart-ratio-new").getContext("2d");
540
+ chartInstances.ratioNew = new Chart(ctx, {
541
  type: "doughnut",
542
  data: {
543
  labels: ["Unique", "Duplicate"],
544
  datasets: [
545
  {
546
  data: [unique, dupes],
547
+ backgroundColor: ["#10b981", "#ef4444"],
548
  borderWidth: 0,
549
  },
550
  ],
551
  },
552
  options: {
553
  maintainAspectRatio: false,
554
+ plugins: { legend: { position: "right", labels: { color: "#ccc" } } },
 
 
555
  },
556
  });
557
 
558
+ // Pipeline Steps (Optional)
559
+ const steps = [
560
+ { name: "Extraction", t: data.performance?.extraction_time },
561
+ { name: "Clustering", t: data.performance?.stage1_cluster_time },
562
+ { name: "Scoring", t: data.performance?.quality_scoring_time },
563
+ ];
564
+ document.getElementById("pipeline-steps").innerHTML = steps
565
+ .map(
566
+ (s) =>
567
+ `<div class="pipeline-step done"><span class="text-xs text-white font-bold">${
568
+ s.name
569
+ }</span> <span class="text-xs text-emerald-400">${s.t?.toFixed(
570
+ 2
571
+ )}s</span></div>`
572
+ )
573
+ .join("");
 
 
 
 
 
 
 
 
 
 
 
 
574
  }
575
 
576
+ // --- HELPER: Action Center (High Priority Clusters) ---
577
  function renderActionCenter(unique, dupes, total) {
578
+ document.getElementById("summary-session-id").textContent =
579
+ currentSessionId || "N/A";
580
+ document.getElementById("summary-savings-text").textContent =
581
+ total > 0 ? ((dupes / total) * 100).toFixed(1) + "%" : "0%";
 
582
 
583
  document.getElementById("sum-total-img").textContent = total;
584
  document.getElementById("sum-dupes-img").textContent = dupes;
 
591
  .sort((a, b) => b[1].length - a[1].length)
592
  .slice(0, 8);
593
 
 
 
 
 
 
594
  sortedGroups.forEach(([name, files], index) => {
595
+ const url = `${API_URL}/results/${currentSessionId}/clusters/${files[0]}`;
 
 
 
 
 
596
  const div = document.createElement("div");
597
+ div.className = `highlight-card rounded-xl p-4 cursor-pointer group flex flex-col gap-3`;
 
 
 
598
  div.innerHTML = `
599
+ <div class="flex justify-between items-start">
600
+ <span class="text-white font-bold text-sm truncate w-full">${name}</span>
601
+ <span class="impact-badge text-[10px] font-bold px-2 py-1 rounded">${files.length}</span>
602
+ </div>
603
+ <div class="highlight-img-container aspect-video w-full bg-black rounded-lg border border-[#333]">
604
+ <img src="${url}" class="w-full h-full object-contain opacity-80 group-hover:opacity-100 transition-opacity duration-300" loading="lazy">
605
+ </div>
606
+ `;
 
 
 
 
 
 
 
 
 
 
 
607
  div.onclick = () => {
608
+ document.querySelector('[data-tab="browser"]').click();
609
+ setTimeout(() => loadCluster(name), 150);
 
 
 
 
 
610
  };
 
611
  grid.appendChild(div);
612
  });
613
  }
614
 
615
+ // UI Buttons (Select All / Keep Best / Image Modal)
616
  document.getElementById("select-all-btn").onclick = () =>
617
  document.querySelectorAll(".thumbnail-card").forEach((c) => {
618
  c.classList.add("selected");
619
  c.querySelector("input").checked = true;
620
  });
 
621
  document.getElementById("deselect-all-btn").onclick = () =>
622
  document.querySelectorAll(".thumbnail-card").forEach((c) => {
623
  c.classList.remove("selected");
624
  c.querySelector("input").checked = false;
625
  });
 
626
  document.getElementById("keep-best-btn").onclick = () => {
627
  const best = qualityScores[currentClusterName]?.images.find((i) => i.is_best);
628
  if (best) {
629
+ document.getElementById("deselect-all-btn").click();
630
+ const c = document.querySelector(`[data-path="${best.path}"]`);
631
+ if (c) {
632
+ c.classList.add("selected");
633
+ c.querySelector("input").checked = true;
 
 
 
634
  }
635
  }
636
  };
637
+ document.getElementById("image-modal").onclick = function (e) {
638
+ if (e.target === this) this.classList.add("hidden");
 
 
 
639
  };
640
+ window.addEventListener("beforeunload", () =>
641
+ Object.values(chartInstances).forEach((c) => c && c.destroy())
642
+ );