")) 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")}: ${result.query_id} - ${t("results.estimatedTime")}: ${result.elapsed_seconds.toFixed(1)} s`;
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 = `${t("labels.description")}: ${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) => (
`${translatedLevel(item.level)}${item.label}${formatScore(item.score)}`
)).join("");
document.querySelector(".cluster-cols").innerHTML = `
${t("cluster.assigned")}${result.cluster.id}${t("cluster.size")}
${Number(result.cluster.size || 0).toLocaleString("en-US")} ${t("cluster.proteins")}${t("cluster.avgSimilarity")}
${Number(result.cluster.avg_similarity || 0).toFixed(2)}
${t("cluster.functions")}${(result.cluster.functions || []).map((item) => `${item}`).join("")}
${t("cluster.organisms")}${(result.cluster.organisms || []).map((item) => `- ${item}
`).join("")}
`;
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();