w4nn4b3M4ST3R commited on
Commit
c5c5cec
·
1 Parent(s): d1036b9
app/index.html CHANGED
@@ -729,7 +729,76 @@
729
  </div>
730
  </div>
731
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  <script type="module" src="/static/script.js"></script>
734
  </body>
735
  </html>
 
729
  </div>
730
  </div>
731
  </div>
732
+ <div id="tour-hud" class="hidden fixed inset-0 pointer-events-none z-50">
733
+ <!-- Top Bar -->
734
+ <div class="absolute top-8 left-1/2 transform -translate-x-1/2 tour-hud">
735
+ <div class="relative bg-black/80 backdrop-blur-xl border border-violet-500/30 rounded-lg px-8 py-4 scanline-effect">
736
+ <div class="text-center">
737
+ <div class="text-xs text-violet-400 font-bold uppercase tracking-[0.3em] mb-1">
738
+ [ AUTONOMOUS NAVIGATION SYSTEM ]
739
+ </div>
740
+ <h2 class="text-2xl font-bold text-white hologram-text mb-2">
741
+ CLUSTER RECONNAISSANCE
742
+ </h2>
743
+ <div class="flex items-center justify-center gap-4 text-sm">
744
+ <div class="flex items-center gap-2">
745
+ <span class="text-gray-500">TARGET:</span>
746
+ <span id="tour-target" class="data-stream font-bold">CLUSTER_001</span>
747
+ </div>
748
+ <div class="w-px h-4 bg-violet-500/30"></div>
749
+ <div class="flex items-center gap-2">
750
+ <span class="text-gray-500">ASSETS:</span>
751
+ <span id="tour-count" class="text-yellow-400 font-bold">0</span>
752
+ </div>
753
+ <div class="w-px h-4 bg-violet-500/30"></div>
754
+ <div class="flex items-center gap-2">
755
+ <span class="text-gray-500">SECTOR:</span>
756
+ <span id="tour-progress-text" class="text-cyan-400 font-bold">1/8</span>
757
+ </div>
758
+ </div>
759
+ </div>
760
+ </div>
761
+ </div>
762
+
763
+ <!-- Progress Bar -->
764
+ <div class="absolute bottom-12 left-1/2 transform -translate-x-1/2 w-96 tour-hud" style="animation-delay: 0.2s">
765
+ <div class="relative">
766
+ <div class="flex justify-between text-xs text-gray-400 mb-2 font-mono">
767
+ <span>SCAN PROGRESS</span>
768
+ <span id="tour-percent">0%</span>
769
+ </div>
770
+
771
+ <div class="relative h-3 bg-black/60 backdrop-blur-sm border border-violet-500/30 rounded-full overflow-hidden">
772
+ <div class="absolute inset-0 bg-gradient-to-r from-violet-900/20 via-violet-700/20 to-violet-900/20 animate-pulse"></div>
773
+
774
+ <div id="tour-progress-bar"
775
+ class="relative h-full bg-gradient-to-r from-violet-600 via-fuchsia-500 to-violet-600 progress-bar-glow transition-all duration-300"
776
+ style="width: 0%">
777
+ <div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-pulse"></div>
778
+ </div>
779
+ </div>
780
 
781
+ <div class="text-center text-xs text-violet-400 mt-2 font-mono">
782
+ <span class="flicker">></span> AUTO-NAVIGATION ACTIVE <span class="flicker"><</span>
783
+ </div>
784
+ </div>
785
+ </div>
786
+
787
+ <!-- Stop Button -->
788
+ <div class="absolute bottom-12 right-12 tour-hud" style="animation-delay: 0.3s">
789
+ <button id="tour-stop-btn"
790
+ class="pointer-events-auto relative group bg-red-900/20 hover:bg-red-900/40 border-2 border-red-500/50 hover:border-red-400 text-red-400 font-bold px-6 py-3 rounded-lg transition-all duration-300 transform hover:scale-105">
791
+ <div class="absolute inset-0 bg-red-500/20 blur-xl group-hover:blur-2xl transition-all"></div>
792
+ <div class="relative flex items-center gap-2">
793
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
794
+ <rect x="6" y="4" width="4" height="16" rx="1"/>
795
+ <rect x="14" y="4" width="4" height="16" rx="1"/>
796
+ </svg>
797
+ <span class="uppercase tracking-wider">ABORT TOUR</span>
798
+ </div>
799
+ </button>
800
+ </div>
801
+ </div>
802
  <script type="module" src="/static/script.js"></script>
803
  </body>
804
  </html>
app/static/Universe3D.js CHANGED
@@ -12,14 +12,35 @@ let activeLines = []; // Mảng chứa các đường nối
12
  let starField;
13
  let centroids = {}; // Lưu tâm của từng cluster để tính toán vụ nổ
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  // State quản lý hiệu ứng
16
  let focusedCluster = null;
17
 
18
  // --- CONFIGURATION ---
19
  const PALETTE = {
20
- highlight: 0xffd700, // Vàng rực (Gold)
21
- ghost: 0x8899ac, // Trắng xanh nhạt (Ghost)
22
  default: [0xa78bfa, 0x60a5fa, 0x34d399, 0xfbbf24, 0xf472b6, 0x22d3ee],
 
23
  };
24
 
25
  export function initUniverse(containerId, data, onNodeClick) {
@@ -108,7 +129,44 @@ export function initUniverse(containerId, data, onNodeClick) {
108
 
109
  setupSearchEvents();
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  animate();
 
 
 
 
 
 
 
 
 
 
 
 
112
  }
113
 
114
  // --- HELPER: Tính tâm của từng cụm (Dùng cho vụ nổ) ---
@@ -239,6 +297,409 @@ function generateGalaxy(data) {
239
  });
240
  }
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  // --- SEARCH VISUALIZATION ---
243
  function setupSearchEvents() {
244
  window.addEventListener("universe-search", (e) => {
@@ -435,6 +896,15 @@ function animate() {
435
  }
436
  });
437
 
 
 
 
 
 
 
 
 
 
438
  controls.update();
439
  composer.render();
440
  }
@@ -482,9 +952,28 @@ function onPointerMove(event) {
482
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
483
  raycaster.setFromCamera(pointer, camera);
484
 
485
- if (raycaster.intersectObjects(clusterParticles).length > 0) {
 
 
 
 
 
 
 
 
 
486
  document.body.style.cursor = "pointer";
 
 
 
 
487
  } else {
 
 
 
 
 
488
  document.body.style.cursor = "default";
 
489
  }
490
  }
 
12
  let starField;
13
  let centroids = {}; // Lưu tâm của từng cluster để tính toán vụ nổ
14
 
15
+ // 🆕 NEURAL PATHFINDING
16
+ let neuralPaths = [];
17
+ let hoveredNode = null;
18
+ let pathParticles = [];
19
+
20
+ // 🆕 AUTO TOUR
21
+ let autoTourActive = false;
22
+ let tourIndex = 0;
23
+ let tourClusters = [];
24
+ let tourTimer = 0;
25
+ const TOUR_DURATION = 4;
26
+
27
+ // 🆕 QUANTUM MERGE
28
+ let quantumMerge = {
29
+ blackHole: null,
30
+ particlePool: [],
31
+ shockwave: null,
32
+ active: false
33
+ };
34
+
35
  // State quản lý hiệu ứng
36
  let focusedCluster = null;
37
 
38
  // --- CONFIGURATION ---
39
  const PALETTE = {
40
+ highlight: 0xffd700,
41
+ ghost: 0x8899ac,
42
  default: [0xa78bfa, 0x60a5fa, 0x34d399, 0xfbbf24, 0xf472b6, 0x22d3ee],
43
+ neural: 0x00ffff, // 🆕
44
  };
45
 
46
  export function initUniverse(containerId, data, onNodeClick) {
 
129
 
130
  setupSearchEvents();
131
 
132
+ window.addEventListener('tour-start', () => {
133
+ document.getElementById('tour-hud')?.classList.remove('hidden');
134
+ });
135
+
136
+ window.addEventListener('tour-visit', (e) => {
137
+ const { cluster, count, index, total } = e.detail;
138
+ document.getElementById('tour-target').textContent = cluster;
139
+ document.getElementById('tour-count').textContent = count;
140
+ document.getElementById('tour-progress-text').textContent = `${index}/${total}`;
141
+ });
142
+
143
+ window.addEventListener('tour-progress', (e) => {
144
+ const bar = document.getElementById('tour-progress-bar');
145
+ if (bar) bar.style.width = e.detail + '%';
146
+ });
147
+
148
+ window.addEventListener('tour-complete', () => {
149
+ alert('🎉 Auto Tour Complete!');
150
+ document.getElementById('tour-hud')?.classList.add('hidden');
151
+ });
152
+
153
+ window.addEventListener('tour-stop', () => {
154
+ document.getElementById('tour-hud')?.classList.add('hidden');
155
+ });
156
+
157
  animate();
158
+
159
+ return {
160
+ setOrbit: (enabled) => { controls.autoRotate = enabled; },
161
+ setLines: (enabled) => {
162
+ activeLines.forEach(l => l.visible = enabled);
163
+ neuralPaths.forEach(p => p.visible = enabled);
164
+ },
165
+ triggerAutoTour: startAutoTour,
166
+ stopAutoTour: stopAutoTour,
167
+ performQuantumMerge: performQuantumMerge,
168
+ isQuantumMergeActive: () => quantumMerge.active
169
+ };
170
  }
171
 
172
  // --- HELPER: Tính tâm của từng cụm (Dùng cho vụ nổ) ---
 
297
  });
298
  }
299
 
300
+ function prepareTourData() {
301
+ const clusterSizes = {};
302
+ clusterParticles.forEach(p => {
303
+ if (!p.userData.isNoise) {
304
+ clusterSizes[p.userData.cluster] = (clusterSizes[p.userData.cluster] || 0) + 1;
305
+ }
306
+ });
307
+
308
+ tourClusters = Object.entries(clusterSizes)
309
+ .sort((a, b) => b[1] - a[1])
310
+ .slice(0, 8)
311
+ .map(([name]) => name);
312
+ }
313
+
314
+ function startAutoTour() {
315
+ if (tourClusters.length === 0) return;
316
+
317
+ autoTourActive = true;
318
+ tourIndex = 0;
319
+ tourTimer = 0;
320
+ controls.autoRotate = false;
321
+
322
+ visitTourStop(tourClusters[tourIndex]);
323
+ window.dispatchEvent(new CustomEvent('tour-start'));
324
+ }
325
+
326
+ function stopAutoTour() {
327
+ autoTourActive = false;
328
+ resetExplosion();
329
+ window.dispatchEvent(new CustomEvent('tour-stop'));
330
+ }
331
+
332
+ function visitTourStop(clusterName) {
333
+ triggerClusterExplosion(clusterName);
334
+
335
+ const clusterNodes = clusterParticles.filter(p => p.userData.cluster === clusterName);
336
+ window.dispatchEvent(new CustomEvent('tour-visit', {
337
+ detail: {
338
+ cluster: clusterName,
339
+ count: clusterNodes.length,
340
+ index: tourIndex + 1,
341
+ total: tourClusters.length
342
+ }
343
+ }));
344
+ }
345
+
346
+ function updateAutoTour(delta) {
347
+ if (!autoTourActive) return;
348
+
349
+ tourTimer += delta;
350
+
351
+ if (tourTimer >= TOUR_DURATION) {
352
+ tourTimer = 0;
353
+ tourIndex++;
354
+
355
+ if (tourIndex >= tourClusters.length) {
356
+ stopAutoTour();
357
+ window.dispatchEvent(new CustomEvent('tour-complete'));
358
+ } else {
359
+ visitTourStop(tourClusters[tourIndex]);
360
+ }
361
+ }
362
+
363
+ const progress = (tourTimer / TOUR_DURATION) * 100;
364
+ window.dispatchEvent(new CustomEvent('tour-progress', { detail: progress }));
365
+ }
366
+
367
+ function createNeuralPaths(sourceNode) {
368
+ clearNeuralPaths();
369
+
370
+ const sourceCluster = sourceNode.userData.cluster;
371
+ if (sourceNode.userData.isNoise) return;
372
+
373
+ const relatedNodes = clusterParticles.filter(
374
+ p => p.userData.cluster === sourceCluster && p !== sourceNode
375
+ );
376
+
377
+ if (relatedNodes.length === 0) return;
378
+
379
+ relatedNodes.forEach((targetNode, index) => {
380
+ const curve = new THREE.CatmullRomCurve3([
381
+ sourceNode.position.clone(),
382
+ sourceNode.position.clone().lerp(targetNode.position, 0.5).add(
383
+ new THREE.Vector3(
384
+ (Math.random() - 0.5) * 10,
385
+ (Math.random() - 0.5) * 10,
386
+ (Math.random() - 0.5) * 10
387
+ )
388
+ ),
389
+ targetNode.position.clone()
390
+ ]);
391
+
392
+ const points = curve.getPoints(50);
393
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
394
+
395
+ const material = new THREE.LineBasicMaterial({
396
+ color: PALETTE.neural,
397
+ transparent: true,
398
+ opacity: 0.6,
399
+ linewidth: 2,
400
+ blending: THREE.AdditiveBlending
401
+ });
402
+
403
+ const line = new THREE.Line(geometry, material);
404
+ scene.add(line);
405
+ neuralPaths.push(line);
406
+
407
+ createPathParticle(curve, index * 0.2);
408
+ });
409
+ }
410
+
411
+ function createPathParticle(curve, delay) {
412
+ const geometry = new THREE.SphereGeometry(0.8, 8, 8);
413
+ const material = new THREE.MeshBasicMaterial({
414
+ color: PALETTE.neural,
415
+ transparent: true,
416
+ opacity: 0.8,
417
+ blending: THREE.AdditiveBlending
418
+ });
419
+
420
+ const particle = new THREE.Mesh(geometry, material);
421
+ particle.userData.curve = curve;
422
+ particle.userData.progress = -delay;
423
+ particle.userData.speed = 0.3;
424
+
425
+ scene.add(particle);
426
+ pathParticles.push(particle);
427
+ }
428
+
429
+ function updatePathParticles(delta) {
430
+ pathParticles.forEach(particle => {
431
+ particle.userData.progress += delta * particle.userData.speed;
432
+
433
+ if (particle.userData.progress > 1) {
434
+ particle.userData.progress = 0;
435
+ }
436
+
437
+ if (particle.userData.progress >= 0 && particle.userData.progress <= 1) {
438
+ const point = particle.userData.curve.getPoint(particle.userData.progress);
439
+ particle.position.copy(point);
440
+ particle.visible = true;
441
+
442
+ const scale = 1 + Math.sin(particle.userData.progress * Math.PI) * 0.5;
443
+ particle.scale.setScalar(scale);
444
+ } else {
445
+ particle.visible = false;
446
+ }
447
+ });
448
+ }
449
+
450
+ function clearNeuralPaths() {
451
+ neuralPaths.forEach(line => {
452
+ line.geometry.dispose();
453
+ line.material.dispose();
454
+ scene.remove(line);
455
+ });
456
+ neuralPaths = [];
457
+
458
+ pathParticles.forEach(p => {
459
+ p.geometry.dispose();
460
+ p.material.dispose();
461
+ scene.remove(p);
462
+ });
463
+ pathParticles = [];
464
+ }
465
+
466
+ // 🌌 QUANTUM MERGE SYSTEM
467
+ function createBlackHole(position, color) {
468
+ const coreGeometry = new THREE.SphereGeometry(1.5, 32, 32);
469
+ const coreMaterial = new THREE.MeshBasicMaterial({
470
+ color: 0x000000,
471
+ transparent: true,
472
+ opacity: 0.9,
473
+ });
474
+ const core = new THREE.Mesh(coreGeometry, coreMaterial);
475
+ core.position.copy(position);
476
+
477
+ const ringGeometry = new THREE.TorusGeometry(3, 0.3, 16, 100);
478
+ const ringMaterial = new THREE.MeshBasicMaterial({
479
+ color: color,
480
+ transparent: true,
481
+ opacity: 0.8,
482
+ blending: THREE.AdditiveBlending,
483
+ });
484
+ const ring = new THREE.Mesh(ringGeometry, ringMaterial);
485
+ ring.position.copy(position);
486
+
487
+ const diskGeometry = new THREE.RingGeometry(3, 8, 32);
488
+ const diskMaterial = new THREE.MeshBasicMaterial({
489
+ color: color,
490
+ transparent: true,
491
+ opacity: 0.3,
492
+ side: THREE.DoubleSide,
493
+ blending: THREE.AdditiveBlending,
494
+ });
495
+ const disk = new THREE.Mesh(diskGeometry, diskMaterial);
496
+ disk.position.copy(position);
497
+ disk.rotation.x = Math.PI / 2;
498
+
499
+ const blackHoleGroup = new THREE.Group();
500
+ blackHoleGroup.add(core);
501
+ blackHoleGroup.add(ring);
502
+ blackHoleGroup.add(disk);
503
+
504
+ scene.add(blackHoleGroup);
505
+
506
+ quantumMerge.blackHole = {
507
+ group: blackHoleGroup,
508
+ core,
509
+ ring,
510
+ disk,
511
+ position: position.clone(),
512
+ strength: 0,
513
+ maxStrength: 50,
514
+ spawnTime: 0
515
+ };
516
+
517
+ return quantumMerge.blackHole;
518
+ }
519
+
520
+ function convertToParticles(sprite, count = 30) {
521
+ const particles = [];
522
+ const spritePos = sprite.position.clone();
523
+ const color = new THREE.Color(sprite.userData.originalColor);
524
+
525
+ for (let i = 0; i < count; i++) {
526
+ const geometry = new THREE.SphereGeometry(0.2, 8, 8);
527
+ const material = new THREE.MeshBasicMaterial({
528
+ color: color,
529
+ transparent: true,
530
+ opacity: 1,
531
+ blending: THREE.AdditiveBlending,
532
+ });
533
+
534
+ const particle = new THREE.Mesh(geometry, material);
535
+
536
+ particle.position.set(
537
+ spritePos.x + (Math.random() - 0.5) * 5,
538
+ spritePos.y + (Math.random() - 0.5) * 5,
539
+ spritePos.z + (Math.random() - 0.5) * 5
540
+ );
541
+
542
+ particle.userData.velocity = new THREE.Vector3(
543
+ (Math.random() - 0.5) * 2,
544
+ (Math.random() - 0.5) * 2,
545
+ (Math.random() - 0.5) * 2
546
+ );
547
+ particle.userData.life = 1.0;
548
+ particle.userData.decayRate = 0.015 + Math.random() * 0.01;
549
+
550
+ scene.add(particle);
551
+ particles.push(particle);
552
+ }
553
+
554
+ quantumMerge.particlePool.push(...particles);
555
+ return particles;
556
+ }
557
+
558
+ function updateQuantumMerge(delta) {
559
+ if (!quantumMerge.active) return;
560
+
561
+ const bh = quantumMerge.blackHole;
562
+ if (!bh) return;
563
+
564
+ // Animate black hole spawn
565
+ if (bh.spawnTime < 1) {
566
+ bh.spawnTime += delta * 2;
567
+ const t = Math.min(bh.spawnTime, 1);
568
+ bh.strength = bh.maxStrength * (1 - Math.pow(1 - t, 3));
569
+
570
+ const scale = 0.1 + 0.9 * t;
571
+ bh.group.scale.setScalar(scale);
572
+ }
573
+
574
+ // Rotate black hole
575
+ if (bh.ring) bh.ring.rotation.x += delta * 2;
576
+ if (bh.disk) bh.disk.rotation.z += delta * 0.5;
577
+
578
+ // Update particles physics
579
+ const bhPos = bh.position;
580
+ const strength = bh.strength;
581
+
582
+ quantumMerge.particlePool.forEach((particle, index) => {
583
+ if (particle.userData.life <= 0) {
584
+ scene.remove(particle);
585
+ particle.geometry.dispose();
586
+ particle.material.dispose();
587
+ quantumMerge.particlePool.splice(index, 1);
588
+ return;
589
+ }
590
+
591
+ const direction = new THREE.Vector3().subVectors(bhPos, particle.position);
592
+ const distance = direction.length();
593
+
594
+ const forceMagnitude = strength / (distance * distance + 1);
595
+ direction.normalize().multiplyScalar(forceMagnitude);
596
+
597
+ particle.userData.velocity.add(direction.multiplyScalar(delta));
598
+ particle.position.add(particle.userData.velocity.clone().multiplyScalar(delta * 60));
599
+
600
+ if (distance < 5) {
601
+ particle.userData.life -= particle.userData.decayRate;
602
+ particle.material.opacity = particle.userData.life;
603
+ particle.scale.setScalar(particle.userData.life);
604
+ }
605
+
606
+ particle.rotation.x += 0.1;
607
+ particle.rotation.y += 0.15;
608
+ });
609
+ }
610
+
611
+ function triggerSupernova(callback) {
612
+ if (!quantumMerge.blackHole) return;
613
+
614
+ const bh = quantumMerge.blackHole;
615
+
616
+ // Create shockwave
617
+ const shockGeometry = new THREE.SphereGeometry(1, 32, 32);
618
+ const shockMaterial = new THREE.MeshBasicMaterial({
619
+ color: 0xffffff,
620
+ transparent: true,
621
+ opacity: 0.8,
622
+ wireframe: true,
623
+ blending: THREE.AdditiveBlending,
624
+ });
625
+ const shockwave = new THREE.Mesh(shockGeometry, shockMaterial);
626
+ shockwave.position.copy(bh.position);
627
+ scene.add(shockwave);
628
+ quantumMerge.shockwave = shockwave;
629
+
630
+ let t = 0;
631
+ const animate = () => {
632
+ if (t >= 1) {
633
+ cleanupQuantumMerge();
634
+ if (callback) callback();
635
+ return;
636
+ }
637
+
638
+ t += 0.03;
639
+
640
+ const scale = 1 - t * t * t;
641
+ bh.core.scale.setScalar(scale);
642
+
643
+ bh.ring.scale.setScalar(1 + t * 3);
644
+ bh.ring.material.opacity = 1 - t;
645
+
646
+ shockwave.scale.setScalar(1 + t * 30);
647
+ shockwave.material.opacity = 0.8 * (1 - t);
648
+
649
+ requestAnimationFrame(animate);
650
+ };
651
+ animate();
652
+ }
653
+
654
+ function cleanupQuantumMerge() {
655
+ if (quantumMerge.blackHole) {
656
+ scene.remove(quantumMerge.blackHole.group);
657
+ quantumMerge.blackHole.group.traverse((obj) => {
658
+ if (obj.geometry) obj.geometry.dispose();
659
+ if (obj.material) obj.material.dispose();
660
+ });
661
+ quantumMerge.blackHole = null;
662
+ }
663
+
664
+ if (quantumMerge.shockwave) {
665
+ scene.remove(quantumMerge.shockwave);
666
+ quantumMerge.shockwave.geometry.dispose();
667
+ quantumMerge.shockwave.material.dispose();
668
+ quantumMerge.shockwave = null;
669
+ }
670
+
671
+ quantumMerge.particlePool.forEach((p) => {
672
+ scene.remove(p);
673
+ p.geometry.dispose();
674
+ p.material.dispose();
675
+ });
676
+ quantumMerge.particlePool = [];
677
+ quantumMerge.active = false;
678
+ }
679
+
680
+ function performQuantumMerge(keepSprite, deleteSprites) {
681
+ quantumMerge.active = true;
682
+
683
+ // 1. Create black hole at keep position
684
+ createBlackHole(keepSprite.position, keepSprite.userData.originalColor);
685
+
686
+ // 2. Convert sprites to particles
687
+ deleteSprites.forEach((sprite) => {
688
+ convertToParticles(sprite, 30);
689
+ sprite.visible = false;
690
+ });
691
+
692
+ // 3. After 3 seconds, trigger supernova
693
+ setTimeout(() => {
694
+ triggerSupernova(() => {
695
+ console.log("Quantum merge complete!");
696
+ window.dispatchEvent(new CustomEvent('quantum-merge-complete', {
697
+ detail: { deleteSprites }
698
+ }));
699
+ });
700
+ }, 3000);
701
+ }
702
+
703
  // --- SEARCH VISUALIZATION ---
704
  function setupSearchEvents() {
705
  window.addEventListener("universe-search", (e) => {
 
896
  }
897
  });
898
 
899
+ // 🆕 Update neural path particles
900
+ updatePathParticles(delta);
901
+
902
+ // 🆕 Update auto tour
903
+ updateAutoTour(delta);
904
+
905
+ // 🆕 Update quantum merge
906
+ updateQuantumMerge(delta);
907
+
908
  controls.update();
909
  composer.render();
910
  }
 
952
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
953
  raycaster.setFromCamera(pointer, camera);
954
 
955
+ const intersects = raycaster.intersectObjects(clusterParticles);
956
+
957
+ if (intersects.length > 0) {
958
+ const newHovered = intersects[0].object;
959
+
960
+ if (newHovered !== hoveredNode && !newHovered.userData.isNoise) {
961
+ hoveredNode = newHovered;
962
+ createNeuralPaths(hoveredNode);
963
+ }
964
+
965
  document.body.style.cursor = "pointer";
966
+
967
+ window.dispatchEvent(new CustomEvent('universe-hover', {
968
+ detail: newHovered.userData
969
+ }));
970
  } else {
971
+ if (hoveredNode) {
972
+ clearNeuralPaths();
973
+ hoveredNode = null;
974
+ }
975
+
976
  document.body.style.cursor = "default";
977
+ window.dispatchEvent(new CustomEvent('universe-unhover'));
978
  }
979
  }
app/static/script.js CHANGED
@@ -220,14 +220,15 @@ function renderUniverseMap(points) {
220
 
221
  // Thêm nút "Auto Tour" vào UI nếu chưa có
222
  if (!document.getElementById("btn-autotour")) {
223
- const tourBtn = document.createElement("button");
224
- tourBtn.id = "btn-autotour";
225
- tourBtn.className = "px-3 py-1 bg-violet-500/20 hover:bg-violet-500/40 text-violet-300 text-[10px] font-bold uppercase rounded border border-violet-500/30 transition ml-2";
226
- tourBtn.innerHTML = "▶ Auto Tour";
227
- tourBtn.onclick = () => {
228
- if(universeController) universeController.triggerAutoTour();
229
- };
230
- controlsDiv.appendChild(tourBtn);
 
231
  }
232
 
233
  // Khởi tạo Three.js và lưu controller
@@ -241,21 +242,25 @@ function renderUniverseMap(points) {
241
 
242
  // Kết nối lại các nút Toggle
243
  document.getElementById("toggle-rotate").onchange = (e) => {
244
- if(universeController) universeController.setOrbit(e.target.checked);
245
  };
246
-
247
  document.getElementById("toggle-lines").onchange = (e) => {
248
- if(universeController) universeController.setLines(e.target.checked);
249
  };
250
 
251
  window.addEventListener("universe-hover", (e) => {
252
  const data = e.detail;
253
  const tooltip = document.getElementById("universe-tooltip");
254
- const imgPath = data.path.startsWith("/") ? data.path : `${API_URL}/results/${currentSessionId}/clusters/${data.path}`;
 
 
255
  document.getElementById("tooltip-img").src = imgPath;
256
  document.getElementById("tooltip-name").textContent = data.filename;
257
  document.getElementById("tooltip-cluster").textContent = data.cluster;
258
- document.getElementById("tooltip-score").textContent = data.quality ? data.quality.toFixed(0) : "N/A";
 
 
259
  tooltip.classList.remove("hidden");
260
  });
261
 
@@ -465,13 +470,56 @@ document.getElementById("smart-cleanup-btn").onclick = async () => {
465
  if (sel.length !== 1) return alert("Select exactly ONE best image to keep.");
466
 
467
  const keepPath = sel[0].dataset.path;
468
- if (!confirm(`Keep 1 and delete the rest of '${currentClusterName}'?`))
469
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
 
471
  const allCards = Array.from(document.querySelectorAll(".thumbnail-card"));
472
- const cardsToDelete = allCards.filter(
473
- (card) => card.dataset.path !== keepPath
474
- );
475
  cardsToDelete.forEach((card) => card.classList.add("being-deleted"));
476
 
477
  const btn = document.getElementById("smart-cleanup-btn");
@@ -491,7 +539,7 @@ document.getElementById("smart-cleanup-btn").onclick = async () => {
491
  if (!res.ok) throw new Error((await res.json()).detail);
492
  const data = await res.json();
493
 
494
- await new Promise((r) => setTimeout(r, 800)); // Đợi hiệu ứng
495
 
496
  const oldPaths = currentGroups[currentClusterName];
497
  currentGroups[currentClusterName] = [data.image_kept];
@@ -569,34 +617,44 @@ function renderStatsDashboard(data, unique, dupes) {
569
  if (chartInstances.distNew) chartInstances.distNew.destroy();
570
 
571
  const groups = data.results.groups || {};
572
- const sizes = Object.values(groups).map(g => g.length).sort((a,b) => b-a).slice(0, 10);
573
- const labels = Object.keys(groups).sort((a,b) => groups[b].length - groups[a].length).slice(0, 10);
 
 
 
 
 
574
 
575
  const ctxDist = document.getElementById("chart-dist-new").getContext("2d");
576
  chartInstances.distNew = new Chart(ctxDist, {
577
- type: 'bar',
578
- data: {
579
- labels: labels,
580
- datasets: [{
581
- label: 'Images per Cluster',
582
- data: sizes,
583
- backgroundColor: '#8b5cf6',
584
- borderRadius: 4,
585
- borderWidth: 0
586
- }]
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  },
588
- options: {
589
- responsive: true,
590
- maintainAspectRatio: false,
591
- indexAxis: 'y',
592
- scales: {
593
- x: { grid: { color: '#333' }, ticks: { color: '#888' } },
594
- y: { grid: { display: false }, ticks: { color: '#ccc', font: {size: 10} } }
595
- },
596
- plugins: {
597
- legend: { display: false }
598
- }
599
- }
600
  });
601
 
602
  // Pipeline Steps
@@ -678,9 +736,106 @@ document.getElementById("keep-best-btn").onclick = () => {
678
  }
679
  }
680
  };
 
681
  document.getElementById("image-modal").onclick = function (e) {
682
  if (e.target === this) this.classList.add("hidden");
683
  };
684
  window.addEventListener("beforeunload", () =>
685
  Object.values(chartInstances).forEach((c) => c && c.destroy())
686
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  // Thêm nút "Auto Tour" vào UI nếu chưa có
222
  if (!document.getElementById("btn-autotour")) {
223
+ const tourBtn = document.createElement("button");
224
+ tourBtn.id = "btn-autotour";
225
+ tourBtn.className =
226
+ "px-3 py-1 bg-violet-500/20 hover:bg-violet-500/40 text-violet-300 text-[10px] font-bold uppercase rounded border border-violet-500/30 transition ml-2";
227
+ tourBtn.innerHTML = "▶ Auto Tour";
228
+ tourBtn.onclick = () => {
229
+ if (universeController) universeController.triggerAutoTour();
230
+ };
231
+ controlsDiv.appendChild(tourBtn);
232
  }
233
 
234
  // Khởi tạo Three.js và lưu controller
 
242
 
243
  // Kết nối lại các nút Toggle
244
  document.getElementById("toggle-rotate").onchange = (e) => {
245
+ if (universeController) universeController.setOrbit(e.target.checked);
246
  };
247
+
248
  document.getElementById("toggle-lines").onchange = (e) => {
249
+ if (universeController) universeController.setLines(e.target.checked);
250
  };
251
 
252
  window.addEventListener("universe-hover", (e) => {
253
  const data = e.detail;
254
  const tooltip = document.getElementById("universe-tooltip");
255
+ const imgPath = data.path.startsWith("/")
256
+ ? data.path
257
+ : `${API_URL}/results/${currentSessionId}/clusters/${data.path}`;
258
  document.getElementById("tooltip-img").src = imgPath;
259
  document.getElementById("tooltip-name").textContent = data.filename;
260
  document.getElementById("tooltip-cluster").textContent = data.cluster;
261
+ document.getElementById("tooltip-score").textContent = data.quality
262
+ ? data.quality.toFixed(0)
263
+ : "N/A";
264
  tooltip.classList.remove("hidden");
265
  });
266
 
 
470
  if (sel.length !== 1) return alert("Select exactly ONE best image to keep.");
471
 
472
  const keepPath = sel[0].dataset.path;
473
+ if (!confirm(`Keep 1 and delete the rest of '${currentClusterName}'?`)) return;
474
+
475
+ if (universeController && universeState.data.length > 0) {
476
+ const keepSprite = clusterParticles.find(p => p.userData.path === keepPath);
477
+ const deleteSprites = clusterParticles.filter(
478
+ p => p.userData.cluster === currentClusterName && p.userData.path !== keepPath
479
+ );
480
+
481
+ if (keepSprite && deleteSprites.length > 0) {
482
+ universeController.performQuantumMerge(keepSprite, deleteSprites);
483
+
484
+ const handleComplete = async (e) => {
485
+ window.removeEventListener('quantum-merge-complete', handleComplete);
486
+
487
+ try {
488
+ const res = await fetch(`${API_URL}/smart-cleanup`, {
489
+ method: "POST",
490
+ headers: { "Content-Type": "application/json" },
491
+ body: JSON.stringify({
492
+ session_id: currentSessionId,
493
+ cluster_name: currentClusterName,
494
+ image_to_keep: keepPath,
495
+ }),
496
+ });
497
+
498
+ if (!res.ok) throw new Error("Cleanup failed on server");
499
+
500
+ const data = await res.json();
501
+ // Update UI
502
+ const oldPaths = currentGroups[currentClusterName];
503
+ currentGroups[currentClusterName] = [keepPath];
504
+
505
+ const deletedPaths = data.deleted || oldPaths.filter(p => p !== keepPath);
506
+ syncUniverseMap(deletedPaths);
507
+ loadCluster(currentClusterName);
508
+ renderClusterList();
509
+
510
+ } catch (e) {
511
+ alert("Cleanup failed: " + e.message);
512
+ loadCluster(currentClusterName);
513
+ }
514
+ };
515
+
516
+ window.addEventListener('quantum-merge-complete', handleComplete);
517
+ return;
518
+ }
519
+ }
520
 
521
  const allCards = Array.from(document.querySelectorAll(".thumbnail-card"));
522
+ const cardsToDelete = allCards.filter((card) => card.dataset.path !== keepPath);
 
 
523
  cardsToDelete.forEach((card) => card.classList.add("being-deleted"));
524
 
525
  const btn = document.getElementById("smart-cleanup-btn");
 
539
  if (!res.ok) throw new Error((await res.json()).detail);
540
  const data = await res.json();
541
 
542
+ await new Promise((r) => setTimeout(r, 800));
543
 
544
  const oldPaths = currentGroups[currentClusterName];
545
  currentGroups[currentClusterName] = [data.image_kept];
 
617
  if (chartInstances.distNew) chartInstances.distNew.destroy();
618
 
619
  const groups = data.results.groups || {};
620
+ const sizes = Object.values(groups)
621
+ .map((g) => g.length)
622
+ .sort((a, b) => b - a)
623
+ .slice(0, 10);
624
+ const labels = Object.keys(groups)
625
+ .sort((a, b) => groups[b].length - groups[a].length)
626
+ .slice(0, 10);
627
 
628
  const ctxDist = document.getElementById("chart-dist-new").getContext("2d");
629
  chartInstances.distNew = new Chart(ctxDist, {
630
+ type: "bar",
631
+ data: {
632
+ labels: labels,
633
+ datasets: [
634
+ {
635
+ label: "Images per Cluster",
636
+ data: sizes,
637
+ backgroundColor: "#8b5cf6",
638
+ borderRadius: 4,
639
+ borderWidth: 0,
640
+ },
641
+ ],
642
+ },
643
+ options: {
644
+ responsive: true,
645
+ maintainAspectRatio: false,
646
+ indexAxis: "y",
647
+ scales: {
648
+ x: { grid: { color: "#333" }, ticks: { color: "#888" } },
649
+ y: {
650
+ grid: { display: false },
651
+ ticks: { color: "#ccc", font: { size: 10 } },
652
+ },
653
  },
654
+ plugins: {
655
+ legend: { display: false },
656
+ },
657
+ },
 
 
 
 
 
 
 
 
658
  });
659
 
660
  // Pipeline Steps
 
736
  }
737
  }
738
  };
739
+
740
  document.getElementById("image-modal").onclick = function (e) {
741
  if (e.target === this) this.classList.add("hidden");
742
  };
743
  window.addEventListener("beforeunload", () =>
744
  Object.values(chartInstances).forEach((c) => c && c.destroy())
745
  );
746
+
747
+ const moveModal = document.getElementById("move-modal");
748
+ const moveSelect = document.getElementById("move-cluster-select");
749
+ const moveNewInputGroup = document.getElementById("move-new-cluster-input-group");
750
+ const moveNewInput = document.getElementById("move-new-cluster-name");
751
+
752
+ document.getElementById("move-btn").onclick = () => {
753
+ const selected = document.querySelectorAll(".thumbnail-card.selected");
754
+ if (selected.length === 0) return alert("Please select images to move.");
755
+
756
+ // Populate Select Option
757
+ moveSelect.innerHTML = '<option value="__NEW__">+ Create New Cluster</option>';
758
+ Object.keys(currentGroups).forEach(name => {
759
+ if (name !== currentClusterName) {
760
+ const opt = document.createElement("option");
761
+ opt.value = name;
762
+ opt.textContent = name;
763
+ moveSelect.appendChild(opt);
764
+ }
765
+ });
766
+
767
+ moveModal.classList.remove("hidden");
768
+ moveNewInputGroup.classList.add("hidden"); // Reset
769
+ moveSelect.value = Object.keys(currentGroups).find(n => n !== currentClusterName) || "__NEW__";
770
+ if(moveSelect.value === "__NEW__") moveNewInputGroup.classList.remove("hidden");
771
+ };
772
+
773
+ moveSelect.onchange = () => {
774
+ if (moveSelect.value === "__NEW__") {
775
+ moveNewInputGroup.classList.remove("hidden");
776
+ moveNewInput.focus();
777
+ } else {
778
+ moveNewInputGroup.classList.add("hidden");
779
+ }
780
+ };
781
+
782
+ document.getElementById("move-cancel-btn").onclick = () => {
783
+ moveModal.classList.add("hidden");
784
+ };
785
+
786
+ document.getElementById("move-confirm-btn").onclick = async () => {
787
+ const selectedCards = Array.from(document.querySelectorAll(".thumbnail-card.selected"));
788
+ const imagePaths = selectedCards.map(c => c.dataset.path);
789
+
790
+ let targetCluster = moveSelect.value;
791
+ if (targetCluster === "__NEW__") {
792
+ targetCluster = moveNewInput.value.trim();
793
+ if (!targetCluster) return alert("Please enter a new cluster name.");
794
+ }
795
+
796
+ const btn = document.getElementById("move-confirm-btn");
797
+ btn.disabled = true;
798
+ btn.textContent = "Moving...";
799
+
800
+ try {
801
+ const res = await fetch(`${API_URL}/move-images`, {
802
+ method: "POST",
803
+ headers: { "Content-Type": "application/json" },
804
+ body: JSON.stringify({
805
+ session_id: currentSessionId,
806
+ image_paths: imagePaths,
807
+ target_cluster: targetCluster
808
+ }),
809
+ });
810
+
811
+ if (!res.ok) throw new Error((await res.json()).detail);
812
+
813
+ // Update UI Client-side
814
+ // 1. Remove from current group
815
+ currentGroups[currentClusterName] = currentGroups[currentClusterName].filter(p => !imagePaths.includes(p));
816
+
817
+ // 2. Add to new group (if exists locally) or create new
818
+ if (!currentGroups[targetCluster]) currentGroups[targetCluster] = [];
819
+ currentGroups[targetCluster].push(...imagePaths);
820
+
821
+ // 3. UI Refresh
822
+ selectedCards.forEach(c => c.remove());
823
+ renderClusterList();
824
+ moveModal.classList.add("hidden");
825
+
826
+ if (universeController) {
827
+ syncUniverseMap(imagePaths);
828
+ }
829
+
830
+ alert(`Moved ${imagePaths.length} images to '${targetCluster}'`);
831
+ } catch (e) {
832
+ alert("Move failed: " + e.message);
833
+ } finally {
834
+ btn.disabled = false;
835
+ btn.textContent = "CONFIRM MOVE";
836
+ }
837
+ };
838
+
839
+ document.getElementById('tour-stop-btn')?.addEventListener('click', () => {
840
+ if (universeController) universeController.stopAutoTour();
841
+ });
app/static/styles.css CHANGED
@@ -900,4 +900,73 @@ body {
900
  0% { width: 0%; height: 0%; opacity: 0; }
901
  50% { opacity: 1; width: 40%; height: 40%; }
902
  100% { width: 0%; height: 0%; opacity: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  }
 
900
  0% { width: 0%; height: 0%; opacity: 0; }
901
  50% { opacity: 1; width: 40%; height: 40%; }
902
  100% { width: 0%; height: 0%; opacity: 0; }
903
+ }
904
+
905
+ @keyframes slide-in {
906
+ from {
907
+ transform: translateY(100px);
908
+ opacity: 0;
909
+ }
910
+ to {
911
+ transform: translateY(0);
912
+ opacity: 1;
913
+ }
914
+ }
915
+
916
+ .tour-hud {
917
+ animation: slide-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
918
+ }
919
+
920
+ .hologram-text {
921
+ text-shadow: 0 0 10px rgba(139, 92, 246, 0.8),
922
+ 0 0 20px rgba(139, 92, 246, 0.4);
923
+ animation: pulse-glow 2s ease-in-out infinite;
924
+ }
925
+
926
+ @keyframes pulse-glow {
927
+ 0%, 100% {
928
+ box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
929
+ transform: scale(1);
930
+ }
931
+ 50% {
932
+ box-shadow: 0 0 40px rgba(139, 92, 246, 0.8);
933
+ transform: scale(1.02);
934
+ }
935
+ }
936
+
937
+ .progress-bar-glow {
938
+ box-shadow: 0 0 20px rgba(139, 92, 246, 0.6),
939
+ inset 0 0 20px rgba(139, 92, 246, 0.3);
940
+ }
941
+
942
+ .data-stream {
943
+ font-family: 'Courier New', monospace;
944
+ color: #00ff00;
945
+ text-shadow: 0 0 5px #00ff00;
946
+ }
947
+
948
+ .flicker {
949
+ animation: flicker 0.15s infinite;
950
+ }
951
+
952
+ @keyframes flicker {
953
+ 0%, 100% { opacity: 1; }
954
+ 50% { opacity: 0.8; }
955
+ }
956
+
957
+ .scanline-effect {
958
+ position: relative;
959
+ overflow: hidden;
960
+ }
961
+
962
+ .scanline-effect::before {
963
+ content: '';
964
+ position: absolute;
965
+ top: 0;
966
+ left: 0;
967
+ width: 100%;
968
+ height: 2px;
969
+ background: linear-gradient(to right, transparent, rgba(139, 92, 246, 0.8), transparent);
970
+ animation: scanline 2s linear infinite;
971
+ z-index: 10;
972
  }