Spaces:
Sleeping
Sleeping
| import { initUniverse } from "./Universe3D.js"; | |
| const API_URL = "/api"; | |
| // --- UI CONFIG TOGGLE --- | |
| const toggleConfigBtn = document.getElementById("toggle-config-btn"); | |
| const controlPanel = document.getElementById("control-panel"); | |
| const configArrow = document.getElementById("config-arrow"); | |
| let isConfigOpen = true; | |
| // Xử lý đóng/mở panel cấu hình | |
| if (toggleConfigBtn && controlPanel) { | |
| toggleConfigBtn.addEventListener("click", () => { | |
| isConfigOpen = !isConfigOpen; | |
| if (isConfigOpen) { | |
| controlPanel.classList.remove("h-0", "opacity-0", "p-0", "invisible"); | |
| controlPanel.classList.add("visible"); | |
| controlPanel.style.height = "auto"; | |
| if (configArrow) configArrow.style.transform = "rotate(0deg)"; | |
| const statusText = document.getElementById("config-status-text"); | |
| if (statusText) statusText.textContent = "Hide Config"; | |
| } else { | |
| controlPanel.classList.add("h-0", "opacity-0", "p-0", "invisible"); | |
| controlPanel.classList.remove("visible"); | |
| controlPanel.style.height = "0"; | |
| if (configArrow) configArrow.style.transform = "rotate(-90deg)"; | |
| const statusText = document.getElementById("config-status-text"); | |
| if (statusText) statusText.textContent = "Show Config"; | |
| } | |
| }); | |
| } | |
| // --- GLOBAL STATE --- | |
| let uploadedFiles = null; | |
| let currentSessionId = null; | |
| let currentGroups = {}; | |
| let qualityScores = {}; | |
| let currentClusterName = null; | |
| let universeState = { data: [], currentTab: "summary" }; | |
| let universeController = null; | |
| const chartInstances = { distNew: null, ratioNew: null }; | |
| // --- DOM ELEMENTS --- | |
| const uploadBtn = document.getElementById("btn-upload"); | |
| const uploadStatus = document.getElementById("upload-status"); | |
| const uploadBar = document.getElementById("upload-bar"); | |
| const startClusterBtn = document.getElementById("start-clustering-btn"); | |
| const loadingOverlay = document.getElementById("loading-overlay"); | |
| const fileInput = document.getElementById("image-folder-input"); | |
| // --- EVENT LISTENERS (FIXED) --- | |
| // 1. SỰ KIỆN CHỌN FILE (Đã sửa lỗi không nhận folder) | |
| if (fileInput) { | |
| fileInput.addEventListener("change", (e) => { | |
| console.log("Event 'change' triggered"); // Debug log | |
| // Kiểm tra xem có file nào được chọn không | |
| if (e.target.files && e.target.files.length > 0) { | |
| uploadedFiles = e.target.files; | |
| console.log(`Selected ${uploadedFiles.length} files`); // Debug log | |
| // Cập nhật UI ngay lập tức | |
| if (uploadStatus) { | |
| uploadStatus.textContent = `${uploadedFiles.length} files ready`; | |
| uploadStatus.classList.remove("text-gray-500"); | |
| uploadStatus.classList.add("text-emerald-400", "font-bold"); | |
| } | |
| // Reset thanh tiến trình và nút Upload | |
| if (uploadBar) uploadBar.style.width = "0%"; | |
| if (uploadBtn) { | |
| uploadBtn.textContent = "UPLOAD NOW"; | |
| uploadBtn.disabled = false; | |
| uploadBtn.classList.remove( | |
| "bg-white", | |
| "text-black", | |
| "bg-emerald-500", | |
| "text-white" | |
| ); | |
| // Style cho nút khi đã sẵn sàng | |
| uploadBtn.classList.add( | |
| "bg-violet-600", | |
| "text-white", | |
| "hover:bg-violet-500" | |
| ); | |
| } | |
| } else { | |
| console.log("No files found in event target"); | |
| } | |
| }); | |
| } else { | |
| console.error("ERROR: Input element #image-folder-input not found!"); | |
| } | |
| // 2. STEP 1: UPLOAD FILES | |
| if (uploadBtn) { | |
| uploadBtn.addEventListener("click", async () => { | |
| if (!uploadedFiles || uploadedFiles.length === 0) { | |
| // Nếu bấm Upload mà chưa chọn file, kích hoạt input click | |
| alert("Please select a folder first!"); | |
| if (fileInput) fileInput.click(); | |
| return; | |
| } | |
| uploadBtn.disabled = true; | |
| uploadBtn.innerHTML = `UPLOADING...`; | |
| if (uploadStatus) | |
| uploadStatus.textContent = "Uploading assets to server..."; | |
| if (uploadBar) uploadBar.style.width = "30%"; | |
| const fd = new FormData(); | |
| for (const f of uploadedFiles) { | |
| fd.append("files", f, f.webkitRelativePath || f.name); | |
| } | |
| try { | |
| const res = await fetch(`${API_URL}/upload-session`, { | |
| method: "POST", | |
| body: fd, | |
| }); | |
| if (!res.ok) throw new Error("Server upload failed"); | |
| const data = await res.json(); | |
| currentSessionId = data.session_id; | |
| // Animation thành công | |
| if (uploadBar) uploadBar.style.width = "100%"; | |
| if (uploadStatus) { | |
| uploadStatus.textContent = "✓ Upload Complete"; | |
| uploadStatus.classList.replace("text-gray-500", "text-emerald-400"); | |
| } | |
| uploadBtn.innerHTML = "DONE"; | |
| uploadBtn.classList.remove("bg-violet-600", "hover:bg-violet-500"); | |
| uploadBtn.classList.add("bg-emerald-500", "cursor-default"); | |
| // Kích hoạt Bước 2 (Làm sáng lên) | |
| const step2 = document.getElementById("step-process"); | |
| if (step2) { | |
| step2.classList.remove( | |
| "opacity-40", | |
| "pointer-events-none", | |
| "grayscale" | |
| ); | |
| step2.classList.add("animate-pulse-once"); // Thêm hiệu ứng nháy nhẹ nếu muốn | |
| } | |
| // Làm mờ Bước 1 | |
| const step1 = document.getElementById("step-upload"); | |
| if (step1) step1.classList.add("opacity-50"); | |
| } catch (e) { | |
| alert("Upload Error: " + e.message); | |
| uploadBtn.disabled = false; | |
| uploadBtn.textContent = "RETRY"; | |
| if (uploadBar) uploadBar.style.width = "0%"; | |
| } | |
| }); | |
| } | |
| // 3. STEP 2: RUN CLUSTERING | |
| if (startClusterBtn) { | |
| startClusterBtn.addEventListener("click", async () => { | |
| if (!currentSessionId) return alert("Please upload files first."); | |
| if (isConfigOpen && toggleConfigBtn) toggleConfigBtn.click(); | |
| if (loadingOverlay) loadingOverlay.classList.remove("hidden"); | |
| const loadingText = document.getElementById("loading-text"); | |
| const loadingBar = document.getElementById("loading-bar"); | |
| if (loadingText) | |
| loadingText.textContent = "This process may take a few minutes..."; | |
| if (loadingBar) loadingBar.style.width = "60%"; | |
| const fd = new FormData(); | |
| fd.append("session_id", currentSessionId); | |
| fd.append("algorithm", document.getElementById("algorithm").value); | |
| try { | |
| const res = await fetch(`${API_URL}/run-clustering`, { | |
| method: "POST", | |
| body: fd, | |
| }); | |
| if (!res.ok) throw new Error((await res.json()).detail); | |
| const data = await res.json(); | |
| qualityScores = data.quality_scores || {}; | |
| if (loadingBar) loadingBar.style.width = "100%"; | |
| populateUI(data); | |
| const hero = document.getElementById("hero-landing"); | |
| if (hero) hero.classList.add("hidden"); | |
| setTimeout(() => { | |
| if (loadingOverlay) loadingOverlay.classList.add("hidden"); | |
| }, 800); | |
| } catch (e) { | |
| alert("Processing Error: " + e.message); | |
| if (loadingOverlay) loadingOverlay.classList.add("hidden"); | |
| } | |
| }); | |
| } | |
| // --- UI HELPERS --- | |
| document.querySelectorAll(".tab-button").forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| const newTabId = btn.dataset.tab; | |
| document | |
| .querySelectorAll(".tab-button") | |
| .forEach((b) => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| document.querySelectorAll(".tab-content").forEach((c) => { | |
| c.classList.remove("active-tab", "fade-in"); | |
| c.classList.add("hidden"); | |
| if (c.classList.contains("flex")) c.classList.remove("flex"); | |
| }); | |
| const newContent = document.getElementById(`tab-${newTabId}`); | |
| if (newContent) { | |
| newContent.classList.remove("hidden"); | |
| if (newTabId === "browser") newContent.classList.add("flex"); | |
| requestAnimationFrame(() => { | |
| newContent.classList.add("active-tab", "fade-in"); | |
| }); | |
| } | |
| if (newTabId === "universe") { | |
| universeState.currentTab = "universe"; | |
| window.dispatchEvent(new Event("resize")); | |
| } | |
| }); | |
| }); | |
| function populateUI(data) { | |
| currentGroups = data.results.groups || {}; | |
| const results = data.results; | |
| const total = results.total_images || 0; | |
| const unique = Object.keys(currentGroups).length; | |
| const dupes = total - unique; | |
| if (data.universe_map) { | |
| universeState.data = data.universe_map; | |
| } | |
| const summaryContent = document.getElementById("summary-content"); | |
| const summaryVisuals = document.getElementById("summary-visuals"); | |
| if (summaryContent) summaryContent.classList.add("hidden"); | |
| if (summaryVisuals) summaryVisuals.classList.remove("hidden"); | |
| renderStatsDashboard(data, unique, dupes); | |
| renderActionCenter(unique, dupes, total); | |
| renderClusterList(); | |
| const firstCluster = Object.keys(currentGroups)[0]; | |
| if (firstCluster) { | |
| loadCluster(firstCluster); | |
| } | |
| if (data.universe_map) renderUniverseMap(data.universe_map); | |
| const dlBtn = document.getElementById("download-btn"); | |
| if (dlBtn) { | |
| dlBtn.classList.remove("hidden"); | |
| dlBtn.onclick = () => | |
| (window.location.href = `${API_URL}/download-results/${currentSessionId}`); | |
| } | |
| const delGrpBtn = document.getElementById("delete-group-btn"); | |
| if (delGrpBtn) delGrpBtn.classList.remove("hidden"); | |
| const summaryTab = document.querySelector('[data-tab="summary"]'); | |
| if (summaryTab) summaryTab.click(); | |
| } | |
| // --- CÁC HÀM RENDER --- | |
| function renderUniverseMap(points) { | |
| console.log("=== RENDER UNIVERSE MAP ==="); | |
| console.log("📊 Points received:", points?.length || 0); | |
| if (!points || !points.length) { | |
| console.warn("⚠️ No points to render in Universe Map"); | |
| return; | |
| } | |
| const emptyState = document.getElementById("universe-empty"); | |
| if (emptyState) { | |
| console.log("✅ Hiding empty state"); | |
| emptyState.classList.add("hidden"); | |
| } | |
| const controlsDiv = document.getElementById("map-controls"); | |
| if (controlsDiv) { | |
| console.log("✅ Showing controls"); | |
| controlsDiv.classList.remove("hidden"); | |
| } | |
| console.log("🚀 Calling initUniverse..."); | |
| universeController = initUniverse("plotly-div", points, (nodeData) => { | |
| console.log("🖱️ Node clicked:", nodeData); | |
| if (nodeData.cluster && nodeData.cluster !== "Noise/Unique") { | |
| const browserBtn = document.querySelector('[data-tab="browser"]'); | |
| if (browserBtn) browserBtn.click(); | |
| setTimeout(() => loadCluster(nodeData.cluster), 300); | |
| } | |
| }); | |
| if (!universeController) { | |
| console.error("Universe controller failed to initialize!"); | |
| return; | |
| } | |
| console.log("Universe initialized successfully"); | |
| const rotateToggle = document.getElementById("toggle-rotate"); | |
| const lineToggle = document.getElementById("toggle-lines"); | |
| if (rotateToggle && universeController) { | |
| rotateToggle.checked = true; | |
| rotateToggle.onchange = (e) => { | |
| console.log("🔄 Orbit toggle:", e.target.checked); | |
| if (universeController) universeController.setOrbit(e.target.checked); | |
| }; | |
| } | |
| if (lineToggle && universeController) { | |
| lineToggle.checked = false; | |
| lineToggle.onchange = (e) => { | |
| console.log("🌐 Constellations toggle:", e.target.checked); | |
| if (universeController) universeController.setLines(e.target.checked); | |
| }; | |
| } | |
| window.addEventListener("universe-hover", (e) => { | |
| const data = e.detail; | |
| const tooltip = document.getElementById("universe-tooltip"); | |
| const imgPath = data.path.startsWith("/") | |
| ? data.path | |
| : `${API_URL}/results/${currentSessionId}/clusters/${data.path}`; | |
| const tImg = document.getElementById("tooltip-img"); | |
| if (tImg) tImg.src = imgPath; | |
| const tName = document.getElementById("tooltip-name"); | |
| if (tName) tName.textContent = data.filename; | |
| const tCluster = document.getElementById("tooltip-cluster"); | |
| if (tCluster) tCluster.textContent = data.cluster; | |
| const tScore = document.getElementById("tooltip-score"); | |
| if (tScore) | |
| tScore.textContent = data.quality ? data.quality.toFixed(0) : "N/A"; | |
| if (tooltip) tooltip.classList.remove("hidden"); | |
| }); | |
| window.addEventListener("universe-unhover", () => { | |
| const tooltip = document.getElementById("universe-tooltip"); | |
| if (tooltip) tooltip.classList.add("hidden"); | |
| }); | |
| document.addEventListener("mousemove", (e) => { | |
| const tooltip = document.getElementById("universe-tooltip"); | |
| if (tooltip && !tooltip.classList.contains("hidden")) { | |
| tooltip.style.left = e.clientX + 20 + "px"; | |
| tooltip.style.top = e.clientY + 20 + "px"; | |
| } | |
| }); | |
| } | |
| function syncUniverseMap(deletedPaths) { | |
| if (!universeState.data.length) return; | |
| console.log("Syncing Universe Map, removing:", deletedPaths.length, "points"); | |
| universeState.data = universeState.data.filter( | |
| (p) => !deletedPaths.includes(p.path) | |
| ); | |
| console.log("Remaining points:", universeState.data.length); | |
| } | |
| function renderClusterList() { | |
| const list = document.getElementById("cluster-list"); | |
| if (!list) return; | |
| list.innerHTML = ""; | |
| Object.entries(currentGroups) | |
| .sort((a, b) => b[1].length - a[1].length) | |
| .forEach(([name, files]) => { | |
| const btn = document.createElement("button"); | |
| btn.className = | |
| "cluster-button w-full text-left p-2.5 rounded text-gray-400 hover:bg-[#333] text-xs font-medium mb-1 border-l-2 border-transparent hover:border-violet-500 transition-all"; | |
| btn.innerHTML = `<span class="text-white font-bold">${name}</span> <span class="text-gray-500 ml-1">(${files.length})</span>`; | |
| btn.dataset.clusterName = name; | |
| btn.onclick = () => loadCluster(name); | |
| list.appendChild(btn); | |
| }); | |
| } | |
| function loadCluster(name) { | |
| currentClusterName = name; | |
| document | |
| .querySelectorAll(".cluster-button") | |
| .forEach((b) => | |
| b.classList.remove("active", "bg-[#252526]", "border-violet-500") | |
| ); | |
| const activeBtn = document.querySelector(`[data-cluster-name="${name}"]`); | |
| if (activeBtn) | |
| activeBtn.classList.add("active", "bg-[#252526]", "border-violet-500"); | |
| const gallery = document.getElementById("thumbnail-gallery"); | |
| if (!gallery) return; | |
| gallery.innerHTML = ""; | |
| gallery.className = | |
| "grid gap-2 p-2 md:gap-6 md:p-6 flex-1 overflow-y-auto bg-[#121212] min-h-0"; | |
| const header = document.getElementById("thumbnail-header"); | |
| if (header) header.textContent = `Cluster: ${name}`; | |
| const q = qualityScores[name]?.images || []; | |
| ["delete-btn", "move-btn", "smart-cleanup-btn"].forEach((id) => { | |
| const btn = document.getElementById(id); | |
| if (btn) btn.disabled = false; | |
| }); | |
| currentGroups[name].forEach((path, index) => { | |
| const url = `${API_URL}/results/${currentSessionId}/clusters/${path}`; | |
| let info = null; | |
| if (q) { | |
| info = q.find((i) => i.path === path); | |
| } | |
| if (!info) { | |
| for (const clusterKey in qualityScores) { | |
| if (qualityScores[clusterKey]?.images) { | |
| const found = qualityScores[clusterKey].images.find((i) => i.path === path); | |
| if (found) { | |
| info = found; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| const isBest = info?.is_best; | |
| let scoreColor = "#666"; | |
| if (info && info.scores) { | |
| if (info.scores.total >= 80) scoreColor = "#34d399"; | |
| else if (info.scores.total >= 50) scoreColor = "#fbbf24"; | |
| else scoreColor = "#f87171"; | |
| } | |
| const div = document.createElement("div"); | |
| div.className = `thumbnail-card group ${isBest ? "best-quality" : ""}`; | |
| div.dataset.path = path; | |
| div.innerHTML = ` | |
| <div class="card-image-container"> | |
| <input type="checkbox" class="card-checkbox"> | |
| <div class="card-score" style="color: ${scoreColor}; border-color: ${scoreColor}"> | |
| ${info ? info.scores.total.toFixed(0) : "N/A"} | |
| </div> | |
| <img src="${url}" loading="lazy" alt="image"> | |
| ${isBest ? '<div class="best-banner">★ BEST VERSION ★</div>' : ""} | |
| </div> | |
| <div class="card-filename" title="${path.split("/").pop()}"> | |
| ${path.split("/").pop()} | |
| </div> | |
| `; | |
| const img = div.querySelector("img"); | |
| if (img) { | |
| img.onclick = (e) => { | |
| e.stopPropagation(); | |
| const modal = document.getElementById("image-modal"); | |
| const modalImg = document.getElementById("modal-image"); | |
| if (modal && modalImg) { | |
| modalImg.src = url; | |
| modal.classList.remove("hidden"); | |
| setTimeout(() => modalImg.classList.add("show"), 10); | |
| } | |
| }; | |
| } | |
| div.onclick = (e) => { | |
| if (e.target.tagName !== "INPUT" && e.target.tagName !== "IMG") { | |
| const cb = div.querySelector("input"); | |
| cb.checked = !cb.checked; | |
| div.classList.toggle("selected", cb.checked); | |
| } | |
| }; | |
| const cb = div.querySelector("input"); | |
| if (cb) | |
| cb.onclick = (e) => { | |
| e.stopPropagation(); | |
| div.classList.toggle("selected", cb.checked); | |
| }; | |
| gallery.appendChild(div); | |
| }); | |
| } | |
| const deleteBtn = document.getElementById("delete-btn"); | |
| if (deleteBtn) { | |
| deleteBtn.onclick = async () => { | |
| const selectedCards = Array.from( | |
| document.querySelectorAll(".thumbnail-card.selected") | |
| ); | |
| const paths = selectedCards.map((c) => c.dataset.path); | |
| if (!paths.length) return; | |
| if (!confirm(`Delete ${paths.length} items?`)) return; | |
| selectedCards.forEach((card) => card.classList.add("being-deleted")); | |
| try { | |
| const res = await fetch(`${API_URL}/delete-images`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| session_id: currentSessionId, | |
| image_paths: paths, | |
| }), | |
| }); | |
| if (!res.ok) throw new Error("Server error"); | |
| const data = await res.json(); | |
| selectedCards.forEach((card) => card.remove()); | |
| if (currentClusterName && currentGroups[currentClusterName]) { | |
| currentGroups[currentClusterName] = currentGroups[ | |
| currentClusterName | |
| ].filter((p) => !paths.includes(p)); | |
| } | |
| syncUniverseMap(data.deleted); | |
| } catch (e) { | |
| selectedCards.forEach((card) => card.classList.remove("being-deleted")); | |
| alert("Delete failed: " + e.message); | |
| } | |
| }; | |
| } | |
| document.getElementById("smart-cleanup-btn").onclick = async () => { | |
| console.log("=== SMART CLEANUP BUTTON CLICKED ==="); | |
| const sel = document.querySelectorAll(".thumbnail-card.selected"); | |
| console.log("Selected cards:", sel.length); | |
| if (sel.length !== 1) return alert("Select exactly ONE best image to keep."); | |
| const keepPath = sel[0].dataset.path; | |
| console.log("Keep path:", keepPath); | |
| if (!confirm(`Keep 1 and delete the rest of '${currentClusterName}'?`)) | |
| return; | |
| console.log("Checking universe controller..."); | |
| console.log(" - universeController exists?", !!universeController); | |
| console.log(" - universeState.data.length:", universeState.data.length); | |
| if (universeController && universeState.data.length > 0) { | |
| console.log("Finding sprites..."); | |
| const keepSprite = universeController.findSpriteByPath(keepPath); | |
| console.log(" - keepSprite found?", !!keepSprite); | |
| const deleteSprites = universeController.findSpritesByClusterAndExclude( | |
| currentClusterName, | |
| keepPath | |
| ); | |
| console.log(" - deleteSprites count:", deleteSprites?.length); | |
| if (keepSprite && deleteSprites && deleteSprites.length > 0) { | |
| console.log("✅ All sprites found! Starting Quantum Merge sequence..."); | |
| console.log("1️⃣ Switching to universe tab..."); | |
| document.querySelector('[data-tab="universe"]').click(); | |
| console.log("2️⃣ Waiting 300ms for tab switch..."); | |
| setTimeout(() => { | |
| console.log("3️⃣ Triggering performQuantumMerge..."); | |
| universeController.performQuantumMerge(keepSprite, deleteSprites); | |
| }, 300); | |
| const handleComplete = async (e) => { | |
| console.log("4️⃣ Received quantum-merge-complete event!"); | |
| window.removeEventListener("quantum-merge-complete", handleComplete); | |
| try { | |
| console.log("5️⃣ Calling API..."); | |
| await callSmartCleanupAPI(keepPath); | |
| console.log("6️⃣ Waiting 1s before returning to browser..."); | |
| setTimeout(() => { | |
| console.log("7️⃣ Returning to browser tab"); | |
| document.querySelector('[data-tab="browser"]').click(); | |
| }, 1000); | |
| } catch (err) { | |
| console.error("❌ Error during cleanup:", err); | |
| alert("Cleanup failed: " + err.message); | |
| document.querySelector('[data-tab="browser"]').click(); | |
| loadCluster(currentClusterName); | |
| } | |
| }; | |
| console.log("👂 Adding event listener for quantum-merge-complete"); | |
| window.addEventListener("quantum-merge-complete", handleComplete); | |
| return; | |
| } else { | |
| console.warn("⚠️ Sprites not found or empty"); | |
| } | |
| } else { | |
| console.warn("⚠️ Universe controller not available"); | |
| } | |
| console.log("💫 Fallback: calling API directly without Quantum Merge"); | |
| await callSmartCleanupAPI(keepPath); | |
| }; | |
| async function callSmartCleanupAPI(keepPath) { | |
| console.log("📡 Calling Smart Cleanup API for:", keepPath); | |
| try { | |
| const res = await fetch(`${API_URL}/smart-cleanup`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| session_id: currentSessionId, | |
| cluster_name: currentClusterName, | |
| image_to_keep: keepPath, | |
| }), | |
| }); | |
| if (!res.ok) { | |
| const error = await res.json(); | |
| throw new Error(error.detail || "Server error"); | |
| } | |
| const data = await res.json(); | |
| console.log("✅ API response:", data); | |
| const oldPaths = currentGroups[currentClusterName]; | |
| currentGroups[currentClusterName] = [data.image_kept]; | |
| const deletedPaths = oldPaths.filter((p) => p !== data.image_kept); | |
| console.log("🔄 Updating UI with", deletedPaths.length, "deleted paths"); | |
| syncUniverseMap(deletedPaths); | |
| loadCluster(currentClusterName); | |
| renderClusterList(); | |
| console.log("✅ Smart Cleanup API complete"); | |
| } catch (e) { | |
| console.error("❌ API Error:", e); | |
| throw e; | |
| } | |
| } | |
| if (deleteBtn) { | |
| deleteBtn.onclick = async () => { | |
| const selectedCards = Array.from( | |
| document.querySelectorAll(".thumbnail-card.selected") | |
| ); | |
| const paths = selectedCards.map((c) => c.dataset.path); | |
| if (!paths.length) return; | |
| if (!confirm(`Delete ${paths.length} items?`)) return; | |
| selectedCards.forEach((card) => card.classList.add("being-deleted")); | |
| try { | |
| const res = await fetch(`${API_URL}/delete-images`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| session_id: currentSessionId, | |
| image_paths: paths, | |
| }), | |
| }); | |
| if (!res.ok) throw new Error("Server error"); | |
| const data = await res.json(); | |
| selectedCards.forEach((card) => card.remove()); | |
| if (currentClusterName && currentGroups[currentClusterName]) { | |
| currentGroups[currentClusterName] = currentGroups[ | |
| currentClusterName | |
| ].filter((p) => !paths.includes(p)); | |
| } | |
| syncUniverseMap(data.deleted); | |
| console.log("✅ Images deleted and Universe synced"); | |
| } catch (e) { | |
| selectedCards.forEach((card) => card.classList.remove("being-deleted")); | |
| alert("Delete failed: " + e.message); | |
| } | |
| }; | |
| } | |
| const deleteGroupBtn = document.getElementById("delete-group-btn"); | |
| if (deleteGroupBtn) { | |
| deleteGroupBtn.onclick = async () => { | |
| if (!confirm(`Delete entire group ${currentClusterName}?`)) return; | |
| try { | |
| const res = await fetch(`${API_URL}/delete-group`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| session_id: currentSessionId, | |
| cluster_name: currentClusterName, | |
| }), | |
| }); | |
| if (res.ok) { | |
| const deletedPaths = currentGroups[currentClusterName]; | |
| delete currentGroups[currentClusterName]; | |
| currentClusterName = null; | |
| renderClusterList(); | |
| const gallery = document.getElementById("thumbnail-gallery"); | |
| if (gallery) gallery.innerHTML = ""; | |
| syncUniverseMap(deletedPaths); | |
| console.log("✅ Group deleted and Universe synced"); | |
| } | |
| } catch (e) { | |
| alert(e.message); | |
| } | |
| }; | |
| } | |
| function renderStatsDashboard(data, unique, dupes) { | |
| const statsEmpty = document.getElementById("stats-empty"); | |
| const statsContent = document.getElementById("stats-content"); | |
| if (statsEmpty) statsEmpty.classList.add("hidden"); | |
| if (statsContent) statsContent.classList.remove("hidden"); | |
| const total = data.results.total_images || 0; | |
| const saved = total > 0 ? ((dupes / total) * 100).toFixed(1) : 0; | |
| const elTotal = document.getElementById("d-total"); | |
| if (elTotal) elTotal.textContent = total; | |
| const elDupes = document.getElementById("d-dupes"); | |
| if (elDupes) elDupes.textContent = dupes; | |
| const elSaved = document.getElementById("d-saved"); | |
| if (elSaved) elSaved.textContent = `${saved}%`; | |
| const elClusters = document.getElementById("d-clusters"); | |
| if (elClusters) elClusters.textContent = unique; | |
| if (chartInstances.ratioNew) chartInstances.ratioNew.destroy(); | |
| const ctxRatio = document.getElementById("chart-ratio-new"); | |
| if (ctxRatio) { | |
| chartInstances.ratioNew = new Chart(ctxRatio.getContext("2d"), { | |
| type: "doughnut", | |
| data: { | |
| labels: ["Unique", "Duplicate"], | |
| datasets: [ | |
| { | |
| data: [unique, dupes], | |
| backgroundColor: ["#10b981", "#ef4444"], | |
| borderWidth: 0, | |
| hoverOffset: 4, | |
| }, | |
| ], | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| cutout: "65%", | |
| plugins: { | |
| legend: { | |
| position: "right", | |
| labels: { | |
| color: "#ccc", | |
| font: { size: 13, family: "'Outfit', sans-serif" }, | |
| boxWidth: 14, | |
| padding: 15, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| } | |
| if (chartInstances.distNew) chartInstances.distNew.destroy(); | |
| const groups = data.results.groups || {}; | |
| const sortedKeys = Object.keys(groups) | |
| .sort((a, b) => groups[b].length - groups[a].length) | |
| .slice(0, 8); | |
| const sizes = sortedKeys.map((k) => groups[k].length); | |
| const ctxDist = document.getElementById("chart-dist-new"); | |
| if (ctxDist) { | |
| chartInstances.distNew = new Chart(ctxDist.getContext("2d"), { | |
| type: "bar", | |
| data: { | |
| labels: sortedKeys, | |
| datasets: [ | |
| { | |
| label: "Images", | |
| data: sizes, | |
| backgroundColor: "#8b5cf6", | |
| borderRadius: 6, | |
| barThickness: 24, | |
| }, | |
| ], | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| indexAxis: "y", | |
| scales: { | |
| x: { | |
| grid: { color: "#333" }, | |
| ticks: { color: "#888", font: { size: 11 } }, | |
| }, | |
| y: { | |
| grid: { display: false }, | |
| ticks: { | |
| color: "#e5e7eb", | |
| font: { size: 13, weight: "500", family: "'Outfit', sans-serif" }, | |
| autoSkip: false, | |
| }, | |
| }, | |
| }, | |
| plugins: { legend: { display: false } }, | |
| }, | |
| }); | |
| } | |
| renderPipelineTimeline(total, unique, dupes); | |
| renderRadarChart(unique, dupes, total); | |
| } | |
| function renderPipelineTimeline(total, unique, dupes) { | |
| const baseOverhead = 0.5; | |
| const perImgFactor = 0.02; | |
| const t1 = baseOverhead.toFixed(2); | |
| const t2 = (baseOverhead + total * 0.005).toFixed(2); | |
| const t3 = (baseOverhead + total * perImgFactor * 0.4).toFixed(2); | |
| const t4 = (baseOverhead + total * perImgFactor * 0.8).toFixed(2); | |
| const t5 = (baseOverhead + total * perImgFactor).toFixed(2); | |
| const algoSelect = document.getElementById("algorithm"); | |
| const algoName = algoSelect ? algoSelect.value : "Algorithm"; | |
| const steps = [ | |
| { | |
| title: "Session Initialized", | |
| time: "00:00:00", | |
| status: "done", | |
| desc: "Environment ready", | |
| }, | |
| { | |
| title: `Ingested ${total} Assets`, | |
| time: `00:00:${t2.padStart(2, "0")}`, | |
| status: "done", | |
| desc: "Integrity check passed", | |
| }, | |
| { | |
| title: `${algoName} Processing`, | |
| time: `00:00:${t3.padStart(2, "0")}`, | |
| status: "done", | |
| desc: "Perceptual hashing generated", | |
| }, | |
| { | |
| title: "Feature Extraction", | |
| time: `00:00:${t4.padStart(2, "0")}`, | |
| status: "done", | |
| desc: `Identified ${dupes} duplicates`, | |
| }, | |
| { | |
| title: "Optimization Complete", | |
| time: `00:00:${t5.padStart(2, "0")}`, | |
| status: "current", | |
| desc: `Consolidated into ${unique} clusters`, | |
| }, | |
| ]; | |
| const timelineContainer = document.getElementById("pipeline-steps"); | |
| if (timelineContainer) { | |
| timelineContainer.innerHTML = steps | |
| .map( | |
| (step, index) => ` | |
| <div class="flex gap-4 relative group mb-2"> | |
| ${ | |
| index !== steps.length - 1 | |
| ? '<div class="absolute left-[11px] top-7 bottom-[-12px] w-px bg-[#333] group-hover:bg-violet-500/30 transition-colors"></div>' | |
| : "" | |
| } | |
| <div class="w-6 h-6 rounded-full ${ | |
| step.status === "done" | |
| ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/50" | |
| : "bg-violet-500/20 text-violet-400 border border-violet-500/50 animate-pulse" | |
| } flex items-center justify-center shrink-0 text-xs font-bold z-10 box-content bg-[#1e1e1e]"> | |
| ${step.status === "done" ? "✓" : "●"} | |
| </div> | |
| <div> | |
| <div class="flex items-center gap-3"> | |
| <div class="text-sm text-gray-100 font-bold">${ | |
| step.title | |
| }</div> | |
| <span class="text-[10px] text-gray-500 border border-[#333] px-1.5 py-0.5 rounded font-mono bg-[#181818]">+${ | |
| step.time | |
| }s</span> | |
| </div> | |
| <div class="text-xs text-gray-400 mt-1">${step.desc}</div> | |
| </div> | |
| </div> | |
| ` | |
| ) | |
| .join(""); | |
| } | |
| } | |
| function renderRadarChart(unique, dupes, total) { | |
| if (total === 0) total = 1; | |
| const efficiencyScore = (dupes / total) * 100; | |
| let speedScore = 100 - total / 50; | |
| if (speedScore < 60) speedScore = 60; | |
| const clusterDensity = (dupes / total) * 100; | |
| const qualityScore = 85 + (Math.random() * 10 - 5); | |
| const aiConfidence = 92; | |
| const ctxRadar = document.getElementById("chart-ai-radar"); | |
| if (ctxRadar) { | |
| if (window.aiRadarChart instanceof Chart) { | |
| window.aiRadarChart.destroy(); | |
| } | |
| window.aiRadarChart = new Chart(ctxRadar.getContext("2d"), { | |
| type: "radar", | |
| data: { | |
| labels: [ | |
| "Storage Efficiency", | |
| "Image Quality", | |
| "Processing Speed", | |
| "Cluster Density", | |
| "Confidence", | |
| ], | |
| datasets: [ | |
| { | |
| label: "Current Session", | |
| data: [ | |
| efficiencyScore, | |
| qualityScore, | |
| speedScore, | |
| clusterDensity, | |
| aiConfidence, | |
| ], | |
| fill: true, | |
| backgroundColor: "rgba(139, 92, 246, 0.2)", | |
| borderColor: "#8b5cf6", | |
| pointBackgroundColor: "#fff", | |
| pointBorderColor: "#8b5cf6", | |
| pointHoverBackgroundColor: "#fff", | |
| pointHoverBorderColor: "#8b5cf6", | |
| }, | |
| { | |
| label: "Benchmark", | |
| data: [50, 75, 80, 50, 85], | |
| fill: true, | |
| backgroundColor: "transparent", | |
| borderColor: "#444", | |
| pointBackgroundColor: "transparent", | |
| pointBorderColor: "transparent", | |
| borderDash: [5, 5], | |
| }, | |
| ], | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| elements: { line: { borderWidth: 2 } }, | |
| scales: { | |
| r: { | |
| angleLines: { color: "#333" }, | |
| grid: { color: "#2a2a2a" }, | |
| pointLabels: { | |
| color: "#d1d5db", | |
| font: { size: 13, family: "'Outfit', sans-serif", weight: "500" }, | |
| }, | |
| ticks: { display: false, backdropColor: "transparent" }, | |
| suggestedMin: 0, | |
| suggestedMax: 100, | |
| }, | |
| }, | |
| plugins: { legend: { display: false } }, | |
| }, | |
| }); | |
| } | |
| } | |
| function renderActionCenter(unique, dupes, total) { | |
| const sId = document.getElementById("summary-session-id"); | |
| if (sId) sId.textContent = currentSessionId || "N/A"; | |
| const sSaved = document.getElementById("summary-savings-text"); | |
| if (sSaved) | |
| sSaved.textContent = | |
| total > 0 ? ((dupes / total) * 100).toFixed(1) + "%" : "0%"; | |
| const impactBadge = document.getElementById("summary-impact-badge"); | |
| if (impactBadge) { | |
| if (dupes > 0) impactBadge.classList.remove("hidden"); | |
| else impactBadge.classList.add("hidden"); | |
| } | |
| document.getElementById("sum-total-img").textContent = total; | |
| document.getElementById("sum-dupes-img").textContent = dupes; | |
| document.getElementById("sum-clusters-img").textContent = unique; | |
| const grid = document.getElementById("priority-grid"); | |
| if (!grid) return; | |
| grid.innerHTML = ""; | |
| const sortedGroups = Object.entries(currentGroups) | |
| .sort((a, b) => b[1].length - a[1].length) | |
| .slice(0, 8); | |
| sortedGroups.forEach(([name, files]) => { | |
| const url = `${API_URL}/results/${currentSessionId}/clusters/${files[0]}`; | |
| const div = document.createElement("div"); | |
| div.className = `highlight-card rounded-xl p-4 cursor-pointer group flex flex-col gap-3`; | |
| div.innerHTML = ` | |
| <div class="flex justify-between items-start"> | |
| <span class="text-white font-bold text-sm truncate w-full">${name}</span> | |
| <span class="impact-badge text-[10px] font-bold px-2 py-1 rounded">${files.length}</span> | |
| </div> | |
| <div class="highlight-img-container aspect-video w-full bg-black rounded-lg border border-[#333]"> | |
| <img src="${url}" class="w-full h-full object-contain opacity-80 group-hover:opacity-100 transition-opacity duration-300" loading="lazy"> | |
| </div> | |
| `; | |
| div.onclick = () => { | |
| document.querySelector('[data-tab="browser"]').click(); | |
| setTimeout(() => loadCluster(name), 150); | |
| }; | |
| grid.appendChild(div); | |
| }); | |
| } | |
| const selectAllBtn = document.getElementById("select-all-btn"); | |
| if (selectAllBtn) | |
| selectAllBtn.onclick = () => | |
| document.querySelectorAll(".thumbnail-card").forEach((c) => { | |
| c.classList.add("selected"); | |
| c.querySelector("input").checked = true; | |
| }); | |
| const deselectAllBtn = document.getElementById("deselect-all-btn"); | |
| if (deselectAllBtn) | |
| deselectAllBtn.onclick = () => | |
| document.querySelectorAll(".thumbnail-card").forEach((c) => { | |
| c.classList.remove("selected"); | |
| c.querySelector("input").checked = false; | |
| }); | |
| const keepBestBtn = document.getElementById("keep-best-btn"); | |
| if (keepBestBtn) { | |
| keepBestBtn.onclick = async () => { | |
| const best = qualityScores[currentClusterName]?.images.find( | |
| (i) => i.is_best | |
| ); | |
| if (!best) { | |
| alert("No best image found for this cluster."); | |
| return; | |
| } | |
| document.querySelectorAll(".thumbnail-card").forEach((c) => { | |
| c.classList.remove("selected"); | |
| c.querySelector("input").checked = false; | |
| }); | |
| const bestCard = document.querySelector(`[data-path="${best.path}"]`); | |
| if (bestCard) { | |
| bestCard.classList.add("selected"); | |
| bestCard.querySelector("input").checked = true; | |
| bestCard.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| } | |
| const confirmMsg = `Keep the BEST image and delete ${ | |
| currentGroups[currentClusterName].length - 1 | |
| } others with Quantum Merge animation?`; | |
| if (confirm(confirmMsg)) { | |
| await callSmartCleanupAPI(best.path); | |
| } | |
| }; | |
| } | |
| const imageModal = document.getElementById("image-modal"); | |
| if (imageModal) | |
| imageModal.onclick = function (e) { | |
| if (e.target === this) this.classList.add("hidden"); | |
| }; | |
| const moveBtn = document.getElementById("move-btn"); | |
| const moveModal = document.getElementById("move-modal"); | |
| const moveSelect = document.getElementById("move-cluster-select"); | |
| const moveNewInputGroup = document.getElementById( | |
| "move-new-cluster-input-group" | |
| ); | |
| const moveNewInput = document.getElementById("move-new-cluster-name"); | |
| const moveConfirmBtn = document.getElementById("move-confirm-btn"); | |
| const moveCancelBtn = document.getElementById("move-cancel-btn"); | |
| if (moveBtn && moveModal) { | |
| moveBtn.onclick = () => { | |
| const selectedCards = document.querySelectorAll(".thumbnail-card.selected"); | |
| if (selectedCards.length === 0) | |
| return alert("Please select images to move."); | |
| moveSelect.innerHTML = ""; | |
| const newOption = document.createElement("option"); | |
| newOption.value = "__NEW_CLUSTER__"; | |
| newOption.textContent = "+ Create New Cluster..."; | |
| newOption.className = "text-violet-400 font-bold"; | |
| moveSelect.appendChild(newOption); | |
| Object.keys(currentGroups) | |
| .sort() | |
| .forEach((name) => { | |
| if (name !== currentClusterName) { | |
| const opt = document.createElement("option"); | |
| opt.value = name; | |
| opt.textContent = name; | |
| moveSelect.appendChild(opt); | |
| } | |
| }); | |
| moveSelect.value = | |
| Object.keys(currentGroups).find((n) => n !== currentClusterName) || | |
| "__NEW_CLUSTER__"; | |
| moveNewInputGroup.classList.add("hidden"); | |
| if (moveSelect.value === "__NEW_CLUSTER__") { | |
| moveNewInputGroup.classList.remove("hidden"); | |
| } | |
| moveModal.classList.remove("hidden"); | |
| }; | |
| moveSelect.onchange = () => { | |
| if (moveSelect.value === "__NEW_CLUSTER__") { | |
| moveNewInputGroup.classList.remove("hidden"); | |
| moveNewInput.focus(); | |
| } else { | |
| moveNewInputGroup.classList.add("hidden"); | |
| } | |
| }; | |
| moveCancelBtn.onclick = () => { | |
| moveModal.classList.add("hidden"); | |
| }; | |
| moveConfirmBtn.onclick = async () => { | |
| const selectedCards = Array.from( | |
| document.querySelectorAll(".thumbnail-card.selected") | |
| ); | |
| const imagePaths = selectedCards.map((c) => c.dataset.path); | |
| let targetCluster = moveSelect.value; | |
| if (targetCluster === "__NEW_CLUSTER__") { | |
| targetCluster = moveNewInput.value.trim(); | |
| if (!targetCluster) | |
| return alert("Please enter a name for the new cluster."); | |
| } | |
| const originalBtnText = moveConfirmBtn.textContent; | |
| moveConfirmBtn.textContent = "MOVING..."; | |
| moveConfirmBtn.disabled = true; | |
| try { | |
| const res = await fetch(`${API_URL}/move-images`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| session_id: currentSessionId, | |
| image_paths: imagePaths, | |
| target_cluster: targetCluster, | |
| }), | |
| }); | |
| if (!res.ok) throw new Error((await res.json()).detail || "Move failed"); | |
| currentGroups[currentClusterName] = currentGroups[ | |
| currentClusterName | |
| ].filter((p) => !imagePaths.includes(p)); | |
| if (!currentGroups[targetCluster]) { | |
| currentGroups[targetCluster] = []; | |
| } | |
| currentGroups[targetCluster].push(...imagePaths); | |
| selectedCards.forEach((c) => c.remove()); | |
| syncUniverseMap(imagePaths); | |
| renderClusterList(); | |
| if (currentGroups[currentClusterName].length === 0) { | |
| delete currentGroups[currentClusterName]; | |
| const nextGroup = Object.keys(currentGroups)[0]; | |
| if (nextGroup) loadCluster(nextGroup); | |
| else document.getElementById("thumbnail-gallery").innerHTML = ""; | |
| } | |
| moveModal.classList.add("hidden"); | |
| alert(`Moved ${imagePaths.length} images to '${targetCluster}'`); | |
| } catch (e) { | |
| alert("Error moving images: " + e.message); | |
| } finally { | |
| moveConfirmBtn.textContent = originalBtnText; | |
| moveConfirmBtn.disabled = false; | |
| } | |
| }; | |
| } | |