| const root = document.documentElement; |
| const themeToggle = document.getElementById("theme-toggle"); |
| const langToggle = document.getElementById("lang-toggle"); |
| const resultsArea = document.getElementById("results-area"); |
| const emptyState = document.getElementById("empty-state"); |
| const uploadTrigger = document.getElementById("upload-trigger"); |
| const sequenceFile = document.getElementById("sequence-file"); |
| const downloadResults = document.getElementById("download-results"); |
| const modal = document.getElementById("cluster-modal"); |
| const clusterOpen = document.getElementById("cluster-open"); |
| const clusterBackdrop = modal?.querySelector(".modal-backdrop"); |
| const progressPanel = document.getElementById("progress-panel"); |
| const progressLabel = document.getElementById("progress-label"); |
| const progressCount = document.getElementById("progress-count"); |
| const progressBar = document.getElementById("progress-bar"); |
| const resultNav = document.getElementById("result-nav"); |
| const prevResult = document.getElementById("prev-result"); |
| const nextResult = document.getElementById("next-result"); |
| const resultPosition = document.getElementById("result-position"); |
|
|
| const partAssets = { sequence: "assets/Parts/Seq_Protein.webp" }; |
| const translations = { |
| es: { |
| "brand.subtitle": "Hierarchical Protein Annotation", |
| "hero.title": "Inferencia jerarquica de <span>proteinas de fagos</span>", |
| "hero.subtitle": "Pipeline de IA para anotar secuencias proteicas con una ruta biologica interpretable: origen celular o viral, rama de fago, estado estructural y contexto de cluster.", |
| "actions.upload": "Subir Secuencias FASTA", |
| "actions.download": "Descargar resultados", |
| "pipeline.sequences": "Secuencias", |
| "pipeline.mlps": "MLPs jerarquicos", |
| "pipeline.classification": "Clasificacion", |
| "pipeline.similarity": "Similitud", |
| "pipeline.interpretation": "Interpretacion", |
| "pipeline.biology": "Biologia", |
| "schema.title": "Esquema de clasificacion jerarquica", |
| "results.title": "Resultado de inferencia", |
| "results.queryId": "ID de consulta", |
| "results.estimatedTime": "Tiempo estimado", |
| "empty.title": "No hay inferencia ejecutada", |
| "empty.description": "Sube un archivo FASTA para ejecutar Vydra. Aqui se mostrara el progreso de ProstT5 y luego los resultados por secuencia.", |
| "progress.detectedZero": "0 secuencias detectadas", |
| "progress.fasta": "secuencias en el FASTA", |
| "progress.readingFasta": "Leyendo FASTA", |
| "progress.processingEmbeddings": "Procesando embeddings ProstT5", |
| "progress.complete": "Completado", |
| "progress.processing": "Procesando...", |
| "status.demoComplete": "Prediccion demo completa", |
| "status.noInference": "Sin inferencia", |
| "status.error": "Error", |
| "cards.prediction": "Prediccion principal", |
| "cards.confidence": "Confianza", |
| "cards.topClasses": "Top clases jerarquicas", |
| "cards.clusterInfo": "Informacion del cluster", |
| "labels.class": "Clase", |
| "labels.description": "Descripcion", |
| "labels.level": "Nivel", |
| "confidence.veryHigh": "Muy alta", |
| "confidence.high": "Alta", |
| "confidence.medium": "Media", |
| "confidence.low": "Baja", |
| "cluster.assigned": "Cluster asignado", |
| "cluster.size": "Tamano del cluster", |
| "cluster.avgSimilarity": "Similitud promedio", |
| "cluster.functions": "Funciones asociadas", |
| "cluster.organisms": "Organismos representativos", |
| "cluster.viewDetail": "Ver cluster en detalle", |
| "cluster.proteins": "proteinas", |
| "cluster.moreCount": "+ 25 mas", |
| "nav.previous": "Anterior", |
| "nav.next": "Siguiente", |
| "node.cellular": "Celular", |
| "node.eukaryote": "Eukariota", |
| "node.tenClasses": "10 clases", |
| "modal.bioContext": "Biological cluster context", |
| "modal.level": "Level", |
| "modal.confidence": "Confidence", |
| "modal.clusterSize": "Cluster size", |
| "modal.nearestReference": "Nearest reference", |
| "modal.dominantFamily": "Dominant family", |
| "modal.dominantHost": "Dominant host", |
| "modal.interpretation": "Interpretacion", |
| "modal.interpretationText": "contexto de cluster, no validacion taxonomica directa de la query." |
| }, |
| en: { |
| "brand.subtitle": "Hierarchical Protein Annotation", |
| "hero.title": "Hierarchical inference for <span>phage proteins</span>", |
| "hero.subtitle": "AI pipeline for annotating protein sequences with an interpretable biological route: cellular or viral origin, phage branch, structural state and cluster context.", |
| "actions.upload": "Upload FASTA Sequences", |
| "actions.download": "Download results", |
| "pipeline.sequences": "Sequences", |
| "pipeline.mlps": "Hierarchical MLPs", |
| "pipeline.classification": "Classification", |
| "pipeline.similarity": "Similarity", |
| "pipeline.interpretation": "Interpretation", |
| "pipeline.biology": "Biology", |
| "schema.title": "Hierarchical classification scheme", |
| "results.title": "Inference result", |
| "results.queryId": "Query ID", |
| "results.estimatedTime": "Estimated time", |
| "empty.title": "No inference has run", |
| "empty.description": "Upload a FASTA file to run Vydra. ProstT5 progress and per-sequence results will appear here.", |
| "progress.detectedZero": "0 sequences detected", |
| "progress.fasta": "sequences in the FASTA", |
| "progress.readingFasta": "Reading FASTA", |
| "progress.processingEmbeddings": "Processing ProstT5 embeddings", |
| "progress.complete": "Complete", |
| "progress.processing": "Processing...", |
| "status.demoComplete": "Demo prediction complete", |
| "status.noInference": "No inference", |
| "status.error": "Error", |
| "cards.prediction": "Main prediction", |
| "cards.confidence": "Confidence", |
| "cards.topClasses": "Top hierarchical classes", |
| "cards.clusterInfo": "Cluster information", |
| "labels.class": "Class", |
| "labels.description": "Description", |
| "labels.level": "Level", |
| "confidence.veryHigh": "Very high", |
| "confidence.high": "High", |
| "confidence.medium": "Medium", |
| "confidence.low": "Low", |
| "cluster.assigned": "Assigned cluster", |
| "cluster.size": "Cluster size", |
| "cluster.avgSimilarity": "Average similarity", |
| "cluster.functions": "Associated functions", |
| "cluster.organisms": "Representative organisms", |
| "cluster.viewDetail": "View cluster details", |
| "cluster.proteins": "proteins", |
| "cluster.moreCount": "+ 25 more", |
| "nav.previous": "Previous", |
| "nav.next": "Next", |
| "node.cellular": "Cellular", |
| "node.eukaryote": "Eukaryote", |
| "node.tenClasses": "10 classes", |
| "modal.bioContext": "Biological cluster context", |
| "modal.level": "Level", |
| "modal.confidence": "Confidence", |
| "modal.clusterSize": "Cluster size", |
| "modal.nearestReference": "Nearest reference", |
| "modal.dominantFamily": "Dominant family", |
| "modal.dominantHost": "Dominant host", |
| "modal.interpretation": "Interpretation", |
| "modal.interpretationText": "cluster context, not direct taxonomic validation of the query." |
| } |
| }; |
|
|
| const confidenceLabelKeys = { |
| "muy alta": "confidence.veryHigh", |
| "very high": "confidence.veryHigh", |
| alta: "confidence.high", |
| high: "confidence.high", |
| media: "confidence.medium", |
| medium: "confidence.medium", |
| baja: "confidence.low", |
| low: "confidence.low" |
| }; |
|
|
| function t(key) { |
| return translations[currentLang]?.[key] || translations.es[key] || key; |
| } |
|
|
| function translatedConfidenceLabel(label) { |
| const key = confidenceLabelKeys[String(label || "").trim().toLowerCase()]; |
| return key ? t(key) : label; |
| } |
|
|
| function translatedLevel(level) { |
| return String(level || "").replace(/^(Nivel|Level)(\s*\d*)$/i, `${t("labels.level")}$2`); |
| } |
|
|
| function translatedStatus(status) { |
| const normalized = String(status || "").trim().toLowerCase(); |
| if (["prediccion demo completa", "demo prediction complete"].includes(normalized)) return t("status.demoComplete"); |
| if (["sin inferencia", "no inference"].includes(normalized)) return t("status.noInference"); |
| if (["complete", "completed", "completado", "prediccion completa", "prediction complete"].includes(normalized)) return t("progress.complete"); |
| if (["processing", "procesando", "running"].includes(normalized)) return t("progress.processing"); |
| return status; |
| } |
|
|
| function renderUploadButton(processing = false) { |
| if (!uploadTrigger) return; |
| if (processing) { |
| uploadTrigger.textContent = t("progress.processing"); |
| return; |
| } |
| uploadTrigger.innerHTML = `<img src="assets/seq.webp" alt=""><span data-i18n="actions.upload">${t("actions.upload")}</span><span>→</span>`; |
| } |
|
|
| function preferredTheme() { |
| return "light"; |
| } |
|
|
| function preferredLanguage() { |
| return "en"; |
| } |
|
|
| let currentTheme = preferredTheme(); |
| let currentLang = preferredLanguage(); |
| let allResults = []; |
| let currentResultIndex = 0; |
| let activeJobId = null; |
| let pollTimer = null; |
| let isUploading = false; |
| let progressState = { processed: 0, total: 0, labelKey: "progress.detectedZero" }; |
|
|
| function applyTheme(theme) { |
| currentTheme = theme; |
| root.setAttribute("data-theme", theme); |
| localStorage.setItem("vydra-service-theme", theme); |
| localStorage.setItem("vydra-theme", theme); |
| if (themeToggle) themeToggle.textContent = theme === "dark" ? "Light" : "Dark"; |
| } |
|
|
| function applyLanguage(lang) { |
| currentLang = lang; |
| localStorage.setItem("vydra-service-lang", lang); |
| document.documentElement.lang = lang; |
| document.querySelectorAll("[data-i18n]").forEach((node) => { |
| const key = node.getAttribute("data-i18n"); |
| const text = translations[lang][key]; |
| if (!text) return; |
| if (text.includes("<span>")) node.innerHTML = text; |
| else node.textContent = text; |
| }); |
| if (langToggle) langToggle.textContent = lang === "es" ? "EN" : "ES"; |
| document.title = lang === "es" ? "Vydra | Inferencia jerarquica" : "Vydra | Hierarchical inference"; |
| if (progressPanel && !progressPanel.hidden) setProgress(progressState.processed, progressState.total, progressState.labelKey); |
| if (isUploading) renderUploadButton(true); |
| if (allResults.length) renderInferenceResult(allResults[currentResultIndex]); |
| else showEmptyState(); |
| } |
|
|
| function showEmptyState() { |
| resultsArea?.classList.add("no-results"); |
| clearRouteHighlight(); |
| if (emptyState) emptyState.hidden = false; |
| if (resultNav) resultNav.hidden = true; |
| document.querySelector(".status-dot").textContent = t("status.noInference"); |
| if (downloadResults) downloadResults.hidden = true; |
| } |
|
|
| function routeNodesFromResult(result) { |
| const labels = (result.hierarchy || []).map((item) => String(item.label || "").toLowerCase().replace(/_/g, " ")); |
| const nodes = ["protein-sequence"]; |
| if (labels.some((label) => label.includes("cellular") || label.includes("celular"))) { |
| nodes.push("cellular"); |
| if (labels.some((label) => label.includes("bacteria"))) nodes.push("bacteria"); |
| else if (labels.some((label) => label.includes("archaea"))) nodes.push("archaea"); |
| else if (labels.some((label) => label.includes("euk"))) nodes.push("eukaryote-cellular"); |
| return nodes; |
| } |
| if (labels.some((label) => label.includes("viral"))) nodes.push("viral"); |
| if (labels.some((label) => label.includes("euk")) && !labels.some((label) => label.includes("phage"))) nodes.push("eukaryote-viral"); |
| if (labels.some((label) => label.includes("phage"))) nodes.push("phage"); |
| if (labels.some((label) => label.includes("non") || label.includes("no estructural"))) nodes.push("non-structural", "non-structural-clusters"); |
| else if (labels.some((label) => label.includes("structural") || label.includes("estructural") || label.includes("capsid") || label.includes("tail") || label.includes("portal"))) nodes.push("structural", "structural-10-classes", "structural-clusters"); |
| return nodes; |
| } |
|
|
| function highlightRoute(result) { |
| const diagram = document.querySelector(".tree-diagram"); |
| const nodes = routeNodesFromResult(result); |
| const nodeSet = new Set(nodes); |
| diagram?.classList.add("has-route"); |
| document.querySelectorAll("[data-node]").forEach((node) => node.classList.toggle("route-active", nodeSet.has(node.getAttribute("data-node")))); |
| document.querySelectorAll("[data-edge]").forEach((edge) => { |
| const [from, to] = edge.getAttribute("data-edge").split(" "); |
| edge.classList.toggle("route-active", nodeSet.has(from) && nodeSet.has(to)); |
| }); |
| } |
|
|
| function clearRouteHighlight() { |
| document.querySelector(".tree-diagram")?.classList.remove("has-route"); |
| document.querySelectorAll(".route-active").forEach((node) => node.classList.remove("route-active")); |
| } |
|
|
| function showResultsState() { |
| resultsArea?.classList.remove("no-results"); |
| if (emptyState) emptyState.hidden = true; |
| } |
|
|
| function openModal() { |
| if (!modal) return; |
| modal.classList.add("open"); |
| modal.setAttribute("aria-hidden", "false"); |
| document.body.style.overflow = "hidden"; |
| } |
|
|
| function closeModal() { |
| if (!modal) return; |
| modal.classList.remove("open"); |
| modal.setAttribute("aria-hidden", "true"); |
| document.body.style.overflow = ""; |
| } |
|
|
| function mixColor(from, to, ratio) { |
| const next = from.map((value, index) => Math.round(value + (to[index] - value) * ratio)); |
| return `rgb(${next[0]}, ${next[1]}, ${next[2]})`; |
| } |
|
|
| function confidenceColor(percent) { |
| const clamped = Math.max(0, Math.min(100, percent)); |
| const red = [239, 68, 68]; |
| const yellow = [245, 203, 66]; |
| const green = [54, 221, 93]; |
| if (clamped <= 50) return mixColor(red, yellow, clamped / 50); |
| return mixColor(yellow, green, (clamped - 50) / 50); |
| } |
|
|
| function formatScore(value) { |
| return Number(value).toFixed(3); |
| } |
|
|
| function renderConfidenceRings() { |
| document.querySelectorAll(".confidence-ring").forEach((ring) => { |
| const percent = Number(ring.getAttribute("data-confidence") || 0); |
| const clamped = Math.max(0, Math.min(100, percent)); |
| const circumference = 2 * Math.PI * 46; |
| const progress = ring.querySelector(".confidence-progress"); |
| if (progress) progress.style.strokeDasharray = `${(clamped / 100) * circumference} ${circumference}`; |
| ring.style.setProperty("--confidence-color", confidenceColor(percent)); |
| }); |
| } |
|
|
| function renderInferenceResult(result) { |
| document.querySelector(".status-dot").textContent = translatedStatus(result.status); |
| document.querySelector(".results-header p").innerHTML = `${t("results.queryId")}: <b>${result.query_id}</b> - ${t("results.estimatedTime")}: <b>${result.elapsed_seconds.toFixed(1)} s</b>`; |
| document.querySelector(".prediction-card h3").textContent = result.prediction.route_label; |
| const classPill = document.querySelector(".class-pill"); |
| const classIcon = document.createElement("img"); |
| const classText = document.createElement("span"); |
| classIcon.src = result.prediction.class_icon; |
| classIcon.alt = ""; |
| classText.textContent = `${t("labels.class")}: ${result.prediction.class_label}`; |
| classPill.replaceChildren(classIcon, classText); |
| document.querySelector(".prediction-card p").innerHTML = `<b>${t("labels.description")}:</b> ${result.prediction.description}`; |
| document.querySelector(".capsid-visual").src = result.prediction.visual_asset || partAssets.sequence; |
|
|
| const confidencePercent = Math.round(result.confidence.value * 100); |
| const confidenceRing = document.querySelector(".confidence-ring"); |
| confidenceRing.setAttribute("data-confidence", String(confidencePercent)); |
| confidenceRing.querySelector("strong").textContent = `${confidencePercent}%`; |
| confidenceRing.querySelector("span").textContent = translatedConfidenceLabel(result.confidence.label); |
|
|
| document.querySelector(".classes-card ul").innerHTML = result.hierarchy.map((item) => ( |
| `<li><span class="dot ${item.color}"></span><b>${translatedLevel(item.level)}</b><em>${item.label}</em><strong>${formatScore(item.score)}</strong></li>` |
| )).join(""); |
|
|
| document.querySelector(".cluster-cols").innerHTML = ` |
| <div><span>${t("cluster.assigned")}</span><strong>${result.cluster.id}</strong><small>${t("cluster.size")}<br>${Number(result.cluster.size || 0).toLocaleString("en-US")} ${t("cluster.proteins")}</small><small>${t("cluster.avgSimilarity")}<br>${Number(result.cluster.avg_similarity || 0).toFixed(2)}</small></div> |
| <div><span>${t("cluster.functions")}</span>${(result.cluster.functions || []).map((item) => `<mark>${item}</mark>`).join("")}</div> |
| <div><span>${t("cluster.organisms")}</span><ul>${(result.cluster.organisms || []).map((item) => `<li>${item}</li>`).join("")}</ul></div> |
| `; |
| renderConfidenceRings(); |
| highlightRoute(result); |
| } |
|
|
| function updateResultNav() { |
| const total = allResults.length; |
| if (!total) return; |
| resultNav.hidden = total <= 1; |
| resultPosition.textContent = `${currentResultIndex + 1} / ${total}`; |
| prevResult.disabled = currentResultIndex === 0; |
| nextResult.disabled = currentResultIndex === total - 1; |
| } |
|
|
| function showResultAt(index) { |
| if (!allResults.length) return; |
| currentResultIndex = Math.max(0, Math.min(allResults.length - 1, index)); |
| renderInferenceResult(allResults[currentResultIndex]); |
| updateResultNav(); |
| showResultsState(); |
| } |
|
|
| function setProgress(processed, total, labelKey = "progress.processingEmbeddings") { |
| const safeTotal = Math.max(0, Number(total || 0)); |
| const safeProcessed = Math.max(0, Number(processed || 0)); |
| progressState = { processed: safeProcessed, total: safeTotal, labelKey }; |
| const label = translations[currentLang]?.[labelKey] ? t(labelKey) : labelKey; |
| progressPanel.hidden = false; |
| progressLabel.textContent = `${safeTotal} ${t("progress.fasta")} - ${label}`; |
| progressCount.textContent = `${safeProcessed} / ${safeTotal}`; |
| progressBar.style.width = safeTotal ? `${Math.min(100, (safeProcessed / safeTotal) * 100)}%` : "0%"; |
| } |
|
|
| async function startFileJob(file) { |
| const form = new FormData(); |
| form.append("file", file); |
| const response = await fetch("/api/jobs-file", { method: "POST", body: form }); |
| if (!response.ok) { |
| const error = await response.json().catch(() => ({})); |
| throw new Error(error.detail || "Inference failed"); |
| } |
| return response.json(); |
| } |
|
|
| async function pollJob(jobId) { |
| const response = await fetch(`/api/jobs/${jobId}`); |
| if (!response.ok) throw new Error(currentLang === "es" ? "No se pudo consultar el progreso" : "Could not check progress"); |
| const job = await response.json(); |
| setProgress(job.processed, job.total, job.stage === "complete" ? "progress.complete" : "progress.processingEmbeddings"); |
| if (job.status === "error") throw new Error(job.error || "Inference failed"); |
| if (job.status === "complete") { |
| clearInterval(pollTimer); |
| pollTimer = null; |
| allResults = job.results || []; |
| currentResultIndex = 0; |
| if (downloadResults && job.download_url) { |
| downloadResults.href = job.download_url; |
| downloadResults.hidden = false; |
| } |
| showResultAt(0); |
| isUploading = false; |
| uploadTrigger.disabled = false; |
| renderUploadButton(false); |
| } |
| } |
|
|
| async function handleSequenceFile(file) { |
| if (!file) return; |
| isUploading = true; |
| renderUploadButton(true); |
| uploadTrigger.disabled = true; |
| if (downloadResults) downloadResults.hidden = true; |
| allResults = []; |
| currentResultIndex = 0; |
| showEmptyState(); |
| setProgress(0, 0, "progress.readingFasta"); |
| try { |
| const job = await startFileJob(file); |
| activeJobId = job.job_id; |
| setProgress(job.processed, job.total, "progress.processingEmbeddings"); |
| if (pollTimer) clearInterval(pollTimer); |
| pollTimer = setInterval(() => pollJob(activeJobId).catch(handleJobError), 1000); |
| await pollJob(activeJobId); |
| } catch (error) { |
| handleJobError(error); |
| } finally { |
| sequenceFile.value = ""; |
| } |
| } |
|
|
| function handleJobError(error) { |
| if (pollTimer) clearInterval(pollTimer); |
| pollTimer = null; |
| isUploading = false; |
| uploadTrigger.disabled = false; |
| renderUploadButton(false); |
| progressLabel.textContent = error.message; |
| progressCount.textContent = t("status.error"); |
| } |
|
|
| themeToggle?.addEventListener("click", () => applyTheme(currentTheme === "light" ? "dark" : "light")); |
| langToggle?.addEventListener("click", () => applyLanguage(currentLang === "es" ? "en" : "es")); |
| uploadTrigger?.addEventListener("click", () => sequenceFile?.click()); |
| sequenceFile?.addEventListener("change", () => handleSequenceFile(sequenceFile.files?.[0])); |
| prevResult?.addEventListener("click", () => showResultAt(currentResultIndex - 1)); |
| nextResult?.addEventListener("click", () => showResultAt(currentResultIndex + 1)); |
| clusterOpen?.addEventListener("click", openModal); |
| clusterBackdrop?.addEventListener("click", closeModal); |
|
|
| window.addEventListener("keydown", (event) => { |
| if (event.key === "Escape") closeModal(); |
| }); |
|
|
| applyTheme(currentTheme); |
| applyLanguage(currentLang); |
| showEmptyState(); |
|
|